@yeaft/webchat-agent 0.1.223 → 0.1.225

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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Conductor — 全局并发信号量
3
+ *
4
+ * 控制 Claude 实例并发上限。所有需要启动 Claude 进程的地方
5
+ * 都必须先 acquire,用完 release。
6
+ *
7
+ * 使用方式:
8
+ * const release = await semaphore.acquire();
9
+ * try { ... } finally { release(); }
10
+ */
11
+
12
+ export class Semaphore {
13
+ /**
14
+ * @param {number} max - 最大并发数
15
+ */
16
+ constructor(max = 5) {
17
+ this._max = max;
18
+ this._current = 0;
19
+ this._queue = [];
20
+ }
21
+
22
+ get current() { return this._current; }
23
+ get max() { return this._max; }
24
+ get waiting() { return this._queue.length; }
25
+
26
+ /**
27
+ * 获取一个许可。如果已满则等待。
28
+ * @returns {Promise<() => void>} release 函数
29
+ */
30
+ acquire() {
31
+ if (this._current < this._max) {
32
+ this._current++;
33
+ return Promise.resolve(this._createRelease());
34
+ }
35
+
36
+ return new Promise(resolve => {
37
+ this._queue.push(() => {
38
+ this._current++;
39
+ resolve(this._createRelease());
40
+ });
41
+ });
42
+ }
43
+
44
+ /**
45
+ * 尝试非阻塞获取。返回 release 函数或 null。
46
+ * @returns {(() => void) | null}
47
+ */
48
+ tryAcquire() {
49
+ if (this._current < this._max) {
50
+ this._current++;
51
+ return this._createRelease();
52
+ }
53
+ return null;
54
+ }
55
+
56
+ _createRelease() {
57
+ let released = false;
58
+ return () => {
59
+ if (released) return;
60
+ released = true;
61
+ this._current--;
62
+ if (this._queue.length > 0) {
63
+ const next = this._queue.shift();
64
+ next();
65
+ }
66
+ };
67
+ }
68
+ }
69
+
70
+ /** 全局单例 */
71
+ export const globalSemaphore = new Semaphore(
72
+ parseInt(process.env.CONDUCTOR_MAX_CLAUDES || '5', 10)
73
+ );
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Conductor — Session 核心
3
+ *
4
+ * 管理 Conductor session 的完整生命周期。
5
+ * 与 V1 Crew 的关键区别:
6
+ * - 不绑定固定工作路径,workDir 可动态切换
7
+ * - 不定义固定角色列表,task 创建时由 orchestrator 决定
8
+ * - session 级元数据存在 ~/.claude/conductor/<sessionId>/
9
+ */
10
+ import { randomUUID } from 'crypto';
11
+ import ctx from '../context.js';
12
+ import {
13
+ loadConductorIndex, upsertConductorIndex,
14
+ loadSessionMeta, saveSessionMeta, loadSessionMessages,
15
+ getMaxShardIndex, getSessionDataDir, initSessionDataDir,
16
+ cleanupMessageShards
17
+ } from './persistence.js';
18
+ import {
19
+ sendConductorMessage, sendConductorOutput, sendStatusUpdate, recordUserMessage
20
+ } from './ui-messages.js';
21
+ import { createConductorClaude, sendToConductor, stopConductorClaude } from './conductor-claude.js';
22
+
23
+ // =====================================================================
24
+ // Data Structures
25
+ // =====================================================================
26
+
27
+ /** @type {Map<string, ConductorSession>} */
28
+ export const conductorSessions = new Map();
29
+
30
+ // =====================================================================
31
+ // Session Lifecycle
32
+ // =====================================================================
33
+
34
+ /**
35
+ * 创建 Conductor Session
36
+ */
37
+ export async function createConductorSession(msg) {
38
+ const {
39
+ sessionId = randomUUID(),
40
+ name = '',
41
+ workDir = null,
42
+ scenarioId = null,
43
+ userId,
44
+ username
45
+ } = msg;
46
+
47
+ const session = {
48
+ id: sessionId,
49
+ name: name || 'Conductor',
50
+ workDir,
51
+ scenarioId,
52
+ status: 'running',
53
+ tasks: new Map(),
54
+ conductorState: null,
55
+ costUsd: 0,
56
+ totalInputTokens: 0,
57
+ totalOutputTokens: 0,
58
+ activeClaudes: 0,
59
+ uiMessages: [],
60
+ userId,
61
+ username,
62
+ agentId: ctx.CONFIG?.agentName || null,
63
+ createdAt: Date.now(),
64
+ _rotating: false,
65
+ _conductorSemRelease: null
66
+ };
67
+
68
+ conductorSessions.set(sessionId, session);
69
+
70
+ // 初始化数据目录
71
+ await initSessionDataDir(sessionId);
72
+
73
+ // 通知前端
74
+ sendConductorMessage({
75
+ type: 'conductor_session_created',
76
+ sessionId,
77
+ name: session.name,
78
+ workDir: session.workDir,
79
+ scenarioId: session.scenarioId,
80
+ userId,
81
+ username
82
+ });
83
+
84
+ sendStatusUpdate(session);
85
+
86
+ // 注册到全局 index
87
+ await upsertConductorIndex(session);
88
+ await saveSessionMeta(session);
89
+
90
+ // 启动 Conductor Claude
91
+ try {
92
+ await createConductorClaude(session);
93
+ console.log(`[Conductor] Session ${sessionId} created, Claude ready`);
94
+ } catch (e) {
95
+ console.error('[Conductor] Failed to start Claude:', e.message);
96
+ sendConductorOutput(session, 'system', {
97
+ message: { role: 'assistant', content: `Conductor 启动失败: ${e.message}` }
98
+ });
99
+ }
100
+
101
+ return session;
102
+ }
103
+
104
+ /**
105
+ * 列出所有 conductor sessions
106
+ */
107
+ export async function handleListConductorSessions(msg) {
108
+ const { requestId, _requestClientId } = msg;
109
+ const index = await loadConductorIndex();
110
+
111
+ const agentId = ctx.CONFIG?.agentName || null;
112
+ const filtered = agentId
113
+ ? index.filter(e => !e.agentId || e.agentId === agentId)
114
+ : index;
115
+
116
+ // 更新活跃 session 的状态
117
+ for (const entry of filtered) {
118
+ const active = conductorSessions.get(entry.sessionId);
119
+ if (active) {
120
+ entry.status = active.status;
121
+ }
122
+ }
123
+
124
+ ctx.sendToServer({
125
+ type: 'conductor_sessions_list',
126
+ requestId,
127
+ _requestClientId,
128
+ sessions: filtered.filter(e => !e.hidden)
129
+ });
130
+ }
131
+
132
+ /**
133
+ * 恢复已停止的 conductor session
134
+ */
135
+ export async function resumeConductorSession(msg) {
136
+ const { sessionId, userId, username } = msg;
137
+
138
+ // 已在内存中
139
+ if (conductorSessions.has(sessionId)) {
140
+ const session = conductorSessions.get(sessionId);
141
+ // 加载 UI 消息
142
+ if (!session.uiMessages || session.uiMessages.length === 0) {
143
+ const loaded = await loadSessionMessages(sessionId);
144
+ session.uiMessages = loaded.messages;
145
+ }
146
+ const cleaned = (session.uiMessages || []).map(m => {
147
+ const { _streaming, ...rest } = m;
148
+ return rest;
149
+ });
150
+ const dir = getSessionDataDir(sessionId);
151
+ const hasOlderMessages = await getMaxShardIndex(dir) > 0;
152
+
153
+ sendConductorMessage({
154
+ type: 'conductor_session_restored',
155
+ sessionId,
156
+ name: session.name,
157
+ workDir: session.workDir,
158
+ scenarioId: session.scenarioId,
159
+ tasks: Array.from(session.tasks.values()),
160
+ userId: session.userId,
161
+ username: session.username,
162
+ uiMessages: cleaned,
163
+ hasOlderMessages
164
+ });
165
+ sendStatusUpdate(session);
166
+ return;
167
+ }
168
+
169
+ // 从磁盘恢复
170
+ const meta = await loadSessionMeta(sessionId);
171
+ if (!meta) {
172
+ sendConductorMessage({
173
+ type: 'error', sessionId,
174
+ message: 'Conductor session not found'
175
+ });
176
+ return;
177
+ }
178
+
179
+ const session = {
180
+ id: sessionId,
181
+ name: meta.name || '',
182
+ workDir: meta.workDir || null,
183
+ scenarioId: meta.scenarioId || null,
184
+ status: 'running',
185
+ tasks: new Map((meta.tasks || []).map(t => [t.taskId, t])),
186
+ conductorState: null,
187
+ costUsd: meta.costUsd || 0,
188
+ totalInputTokens: meta.totalInputTokens || 0,
189
+ totalOutputTokens: meta.totalOutputTokens || 0,
190
+ activeClaudes: 0,
191
+ uiMessages: [],
192
+ userId: userId || meta.userId,
193
+ username: username || meta.username,
194
+ agentId: meta.agentId || ctx.CONFIG?.agentName || null,
195
+ createdAt: meta.createdAt || Date.now(),
196
+ _rotating: false,
197
+ _conductorSemRelease: null
198
+ };
199
+
200
+ conductorSessions.set(sessionId, session);
201
+
202
+ const loaded = await loadSessionMessages(sessionId);
203
+ session.uiMessages = loaded.messages;
204
+
205
+ sendConductorMessage({
206
+ type: 'conductor_session_restored',
207
+ sessionId,
208
+ name: session.name,
209
+ workDir: session.workDir,
210
+ scenarioId: session.scenarioId,
211
+ tasks: Array.from(session.tasks.values()),
212
+ userId: session.userId,
213
+ username: session.username,
214
+ uiMessages: session.uiMessages,
215
+ hasOlderMessages: loaded.hasOlderMessages
216
+ });
217
+ sendStatusUpdate(session);
218
+
219
+ // 启动 Conductor Claude
220
+ try {
221
+ await createConductorClaude(session);
222
+ console.log(`[Conductor] Session ${sessionId} resumed, Claude ready`);
223
+ } catch (e) {
224
+ console.error('[Conductor] Failed to resume Claude:', e.message);
225
+ }
226
+
227
+ await upsertConductorIndex(session);
228
+ await saveSessionMeta(session);
229
+ }
230
+
231
+ /**
232
+ * 处理用户输入
233
+ */
234
+ export async function handleConductorUserInput(msg) {
235
+ const { sessionId, content } = msg;
236
+ const session = conductorSessions.get(sessionId);
237
+ if (!session) {
238
+ console.warn(`[Conductor] Session not found: ${sessionId}`);
239
+ return;
240
+ }
241
+
242
+ // 确保 session 在运行态(先检查再记录,避免停止态也记录消息)
243
+ if (session.status === 'stopped') {
244
+ sendConductorMessage({
245
+ type: 'conductor_error',
246
+ sessionId,
247
+ error: 'Session is stopped'
248
+ });
249
+ return;
250
+ }
251
+
252
+ // 记录到 UI
253
+ recordUserMessage(session, content);
254
+
255
+ session.status = 'running';
256
+
257
+ // 发给 Conductor Claude
258
+ await sendToConductor(session, content);
259
+ }
260
+
261
+ /**
262
+ * 更新工作路径
263
+ */
264
+ export async function handleUpdateWorkDir(msg) {
265
+ const { sessionId, workDir } = msg;
266
+ const session = conductorSessions.get(sessionId);
267
+ if (!session) return;
268
+
269
+ session.workDir = workDir;
270
+ console.log(`[Conductor] Session ${sessionId} workDir updated: ${workDir}`);
271
+
272
+ sendConductorMessage({
273
+ type: 'conductor_workdir_updated',
274
+ sessionId,
275
+ workDir
276
+ });
277
+ sendStatusUpdate(session);
278
+ await saveSessionMeta(session);
279
+ }
280
+
281
+ /**
282
+ * 更新 session name
283
+ */
284
+ export async function handleUpdateConductorSession(msg) {
285
+ const { sessionId, name, workDir } = msg;
286
+ const session = conductorSessions.get(sessionId);
287
+ if (!session) return;
288
+
289
+ if (name !== undefined) session.name = name;
290
+ if (workDir !== undefined) session.workDir = workDir;
291
+ await saveSessionMeta(session);
292
+ await upsertConductorIndex(session);
293
+ }
294
+
295
+ /**
296
+ * 停止 session
297
+ */
298
+ export async function stopConductorSession(sessionId) {
299
+ const session = conductorSessions.get(sessionId);
300
+ if (!session) return;
301
+
302
+ session.status = 'stopped';
303
+
304
+ // 停止 Conductor Claude
305
+ await stopConductorClaude(session);
306
+
307
+ sendConductorOutput(session, 'system', {
308
+ message: { role: 'assistant', content: 'Session 已停止' }
309
+ });
310
+ sendStatusUpdate(session);
311
+ await saveSessionMeta(session);
312
+ await upsertConductorIndex(session);
313
+
314
+ conductorSessions.delete(sessionId);
315
+ console.log(`[Conductor] Session ${sessionId} stopped`);
316
+ }
317
+
318
+ /**
319
+ * 清空 session
320
+ */
321
+ export async function clearConductorSession(sessionId) {
322
+ const session = conductorSessions.get(sessionId);
323
+ if (!session) return;
324
+
325
+ // 停止 Claude
326
+ await stopConductorClaude(session);
327
+
328
+ // 清空数据
329
+ session.tasks.clear();
330
+ session.uiMessages = [];
331
+ session.costUsd = 0;
332
+ session.totalInputTokens = 0;
333
+ session.totalOutputTokens = 0;
334
+
335
+ const dir = getSessionDataDir(sessionId);
336
+ await cleanupMessageShards(dir);
337
+
338
+ session.status = 'running';
339
+
340
+ sendConductorMessage({
341
+ type: 'conductor_session_cleared',
342
+ sessionId
343
+ });
344
+ sendStatusUpdate(session);
345
+
346
+ // 重启 Claude
347
+ try {
348
+ await createConductorClaude(session);
349
+ } catch (e) {
350
+ console.error('[Conductor] Failed to restart Claude after clear:', e.message);
351
+ }
352
+
353
+ await saveSessionMeta(session);
354
+ console.log(`[Conductor] Session ${sessionId} cleared`);
355
+ }