nx-ce 0.1.1 → 0.1.4
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/README.md +90 -51
- package/package.json +3 -2
- package/src/cli.js +16 -2
- package/src/index.js +10 -1
- package/src/serve.js +553 -198
- package/src/session-store.js +56 -2
- package/src/skills.js +56 -0
- package/src/util.js +124 -0
package/src/serve.js
CHANGED
|
@@ -1,241 +1,596 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 服务端 —
|
|
2
|
+
* 服务端 — WebSocket 持久化服务器,支持多会话管理
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 单例进程,对外提供 WebSocket 接口。
|
|
5
|
+
* 每个会话(session)拥有独立的 agentQuery()、MessageChannel 和状态文件,
|
|
6
|
+
* 天然并行,互不阻塞。
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* → { "type":"ping" }
|
|
13
|
-
* ← { "type":"pong", "sessionId":"..." }
|
|
14
|
-
* → { "type":"getSkills" }
|
|
15
|
-
* ← { "type":"skills", "skills":[...], "tools":[...], ... }
|
|
8
|
+
* 竞态保护:
|
|
9
|
+
* - session 创建:pendingCreates Map 防止重复创建
|
|
10
|
+
* - client 绑定:SDK 回复只写 session.client,不走 broadcast
|
|
11
|
+
* - state 文件:每个 session 独立文件,写锁防并发
|
|
12
|
+
* - 消息排序:每个 session 独立单调时钟
|
|
16
13
|
*/
|
|
17
14
|
|
|
15
|
+
import { WebSocketServer } from 'ws';
|
|
18
16
|
import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
19
|
-
import {
|
|
20
|
-
import { readState, writeState, deleteState } from './session-store.js';
|
|
17
|
+
import { hostname, machine, platform, release } from 'node:os';
|
|
18
|
+
import { readState, writeState, deleteState, LifecycleState, createState } from './session-store.js';
|
|
19
|
+
import { generateId, MonotonicClock, getMachineId } from './util.js';
|
|
21
20
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
21
|
+
/** 默认端口 */
|
|
22
|
+
const DEFAULT_PORT = 3100;
|
|
23
|
+
|
|
24
|
+
/** 空闲 session 超时(毫秒),超过此时间无客户端则自动关闭 */
|
|
25
|
+
const SESSION_IDLE_TIMEOUT_MS = 300_000; // 5 分钟
|
|
26
|
+
|
|
27
|
+
// =================================================================
|
|
28
|
+
// SessionManager — 管理多个独立 SDK 会话
|
|
29
|
+
// =================================================================
|
|
30
|
+
|
|
31
|
+
class SessionManager {
|
|
32
|
+
constructor(serverOptions) {
|
|
33
|
+
this.serverOptions = serverOptions; // { claudePath, model, cwd, env }
|
|
34
|
+
|
|
35
|
+
/** @type {Map<string, Session>} */
|
|
36
|
+
this.sessions = new Map();
|
|
37
|
+
|
|
38
|
+
/** 创建中的 Promise,防止并发创建同名 session */
|
|
39
|
+
this._pendingCreates = new Map();
|
|
40
|
+
|
|
41
|
+
/** 清理定时器 */
|
|
42
|
+
this._idleTimers = new Map();
|
|
43
|
+
|
|
44
|
+
/** 会话状态文件写锁 */
|
|
45
|
+
this._writeLocks = new Map();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 获取或创建一个 session。
|
|
50
|
+
* 如果另一个协程正在创建同名 session,则等待其完成。
|
|
51
|
+
*
|
|
52
|
+
* @param {string} name - session 名称(每个客户端/标签页唯一)
|
|
53
|
+
* @returns {Promise<Session>}
|
|
54
|
+
*/
|
|
55
|
+
async getOrCreate(name) {
|
|
56
|
+
// 已有活跃 session → 直接返回
|
|
57
|
+
const existing = this.sessions.get(name);
|
|
58
|
+
if (existing && !existing.closed) {
|
|
59
|
+
// 取消 idle 定时器(客户端回来了)
|
|
60
|
+
this._cancelIdleTimer(name);
|
|
61
|
+
return existing;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 正在被另一个协程创建 → 等它
|
|
65
|
+
if (this._pendingCreates.has(name)) {
|
|
66
|
+
return this._pendingCreates.get(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 创建锁 + 创建
|
|
70
|
+
const promise = this._createSession(name);
|
|
71
|
+
this._pendingCreates.set(name, promise);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return await promise;
|
|
75
|
+
} finally {
|
|
76
|
+
this._pendingCreates.delete(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 创建内部 session 结构。
|
|
82
|
+
* 注意:JS 是单线程 event loop,此函数不会被并发调用(pendingCreates 保证)。
|
|
83
|
+
*/
|
|
84
|
+
async _createSession(name) {
|
|
85
|
+
const { claudePath, model, cwd, env } = this.serverOptions;
|
|
86
|
+
|
|
87
|
+
// 检查是否有可恢复的会话状态
|
|
88
|
+
const existingState = readState(name);
|
|
89
|
+
|
|
90
|
+
// 组装 SDK 选项
|
|
91
|
+
const sdkOptions = {
|
|
92
|
+
cwd: cwd || process.cwd(),
|
|
93
|
+
model: model || 'claude-sonnet-4-6',
|
|
94
|
+
pathToClaudeCodeExecutable: claudePath,
|
|
95
|
+
permissionMode: 'bypassPermissions',
|
|
96
|
+
allowDangerouslySkipPermissions: true,
|
|
97
|
+
env: { ...process.env, ...env },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (existingState?.sessionId) {
|
|
101
|
+
sdkOptions.resume = existingState.sessionId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 消息通道 — SDK 从此处拉取下一条用户消息
|
|
105
|
+
const pendingMessages = [];
|
|
106
|
+
let resolveNext = null;
|
|
107
|
+
let turnActive = false;
|
|
108
|
+
let channelClosed = false;
|
|
109
|
+
|
|
110
|
+
const messageChannel = {
|
|
111
|
+
[Symbol.asyncIterator]() {
|
|
112
|
+
return {
|
|
113
|
+
next: () => {
|
|
114
|
+
while (pendingMessages.length > 0 && !turnActive) {
|
|
115
|
+
turnActive = true;
|
|
116
|
+
return Promise.resolve({ value: pendingMessages.shift(), done: false });
|
|
117
|
+
}
|
|
118
|
+
if (channelClosed) return Promise.resolve({ done: true, value: null });
|
|
119
|
+
return new Promise((resolve) => { resolveNext = resolve; });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function enqueueMessage(sdkUserMessage) {
|
|
126
|
+
if (resolveNext) {
|
|
127
|
+
turnActive = true;
|
|
128
|
+
const r = resolveNext;
|
|
129
|
+
resolveNext = null;
|
|
130
|
+
r({ value: sdkUserMessage, done: false });
|
|
131
|
+
} else if (pendingMessages.length < 8) {
|
|
132
|
+
pendingMessages.push(sdkUserMessage);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function onTurnComplete() {
|
|
137
|
+
turnActive = false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 启动 SDK 持久化查询
|
|
141
|
+
const response = agentQuery({ prompt: messageChannel, options: sdkOptions });
|
|
142
|
+
|
|
143
|
+
/** @type {Session} */
|
|
144
|
+
const session = {
|
|
145
|
+
name,
|
|
146
|
+
messageChannel,
|
|
147
|
+
enqueueMessage,
|
|
148
|
+
onTurnComplete,
|
|
149
|
+
channelClosed: false,
|
|
150
|
+
closeChannel() {
|
|
151
|
+
channelClosed = true;
|
|
152
|
+
if (resolveNext) {
|
|
153
|
+
const r = resolveNext;
|
|
154
|
+
resolveNext = null;
|
|
155
|
+
r({ done: true, value: null });
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
response,
|
|
160
|
+
sdkOptions,
|
|
161
|
+
existingState,
|
|
162
|
+
|
|
163
|
+
// 客户端状态
|
|
164
|
+
client: null, // 当前绑定的 WebSocket 客户端
|
|
165
|
+
queue: [], // 待处理查询 FIFO
|
|
166
|
+
turnActive: false, // SDK 是否正在处理
|
|
167
|
+
currentTurnId: null,
|
|
168
|
+
processing: false,
|
|
169
|
+
|
|
170
|
+
// 元数据
|
|
171
|
+
sessionId: existingState?.sessionId || null,
|
|
172
|
+
metadata: null, // init 消息中的 skills/tools 等
|
|
173
|
+
clock: new MonotonicClock(),
|
|
174
|
+
closed: false,
|
|
175
|
+
|
|
176
|
+
// 消费 Promise(用于等待关闭)
|
|
177
|
+
consumerPromise: null,
|
|
178
|
+
|
|
179
|
+
// usage 追踪
|
|
180
|
+
usage: existingState?.usage || {
|
|
181
|
+
inputTokens: 0,
|
|
182
|
+
outputTokens: 0,
|
|
183
|
+
cacheCreationInputTokens: 0,
|
|
184
|
+
cacheReadInputTokens: 0,
|
|
185
|
+
contextWindow: 200000,
|
|
186
|
+
contextTokens: 0,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// 后台消费 SDK 输出
|
|
191
|
+
session.consumerPromise = this._startConsumer(session);
|
|
192
|
+
|
|
193
|
+
this.sessions.set(name, session);
|
|
194
|
+
|
|
195
|
+
// 持久化初始状态
|
|
196
|
+
this._safeWriteState(session);
|
|
197
|
+
|
|
198
|
+
return session;
|
|
45
199
|
}
|
|
46
200
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
201
|
+
/**
|
|
202
|
+
* 后台消费循环 — 每个 session 独立。
|
|
203
|
+
* SDK 回复只会写入 session.client(绑定的 WS 客户端)。
|
|
204
|
+
*/
|
|
205
|
+
_startConsumer(session) {
|
|
206
|
+
return (async () => {
|
|
207
|
+
try {
|
|
208
|
+
for await (const message of session.response) {
|
|
209
|
+
// init 消息 → 捕获元数据
|
|
210
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
211
|
+
session.sessionId = message.session_id;
|
|
212
|
+
session.metadata = {
|
|
213
|
+
type: 'init',
|
|
214
|
+
sessionId: session.sessionId,
|
|
215
|
+
model: message.model,
|
|
216
|
+
skills: message.skills || [],
|
|
217
|
+
tools: message.tools || [],
|
|
218
|
+
slashCommands: message.slash_commands || [],
|
|
219
|
+
agents: message.agents || [],
|
|
220
|
+
time: session.clock.next(),
|
|
221
|
+
};
|
|
222
|
+
this._safeWriteState(session);
|
|
223
|
+
|
|
224
|
+
// 推给当前绑定的客户端
|
|
225
|
+
this._send(session.client, session.metadata);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 助手消息 → 分块转发
|
|
229
|
+
if (message.type === 'assistant' && message.message?.content) {
|
|
230
|
+
const content = message.message.content;
|
|
231
|
+
if (typeof content === 'string') {
|
|
232
|
+
this._send(session.client, { type: 'text', content, time: session.clock.next() });
|
|
233
|
+
} else if (Array.isArray(content)) {
|
|
234
|
+
for (const block of content) {
|
|
235
|
+
if (block.type === 'text') {
|
|
236
|
+
this._send(session.client, { type: 'text', content: block.text, time: session.clock.next() });
|
|
237
|
+
} else if (block.type === 'tool_use') {
|
|
238
|
+
this._send(session.client, { type: 'tool_use', name: block.name, input: block.input, id: block.id, time: session.clock.next() });
|
|
239
|
+
} else if (block.type === 'thinking') {
|
|
240
|
+
this._send(session.client, { type: 'thinking', content: block.thinking, time: session.clock.next() });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
65
244
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
245
|
+
|
|
246
|
+
// result → 回合结束
|
|
247
|
+
if (message.type === 'result') {
|
|
248
|
+
this._send(session.client, { type: 'done', sessionId: session.sessionId, time: session.clock.next() });
|
|
249
|
+
|
|
250
|
+
// usage 累积
|
|
251
|
+
if (message.usage) {
|
|
252
|
+
const u = message.usage;
|
|
253
|
+
session.usage = {
|
|
254
|
+
inputTokens: (session.usage?.inputTokens || 0) + (u.inputTokens || 0),
|
|
255
|
+
outputTokens: (session.usage?.outputTokens || 0) + (u.outputTokens || 0),
|
|
256
|
+
cacheCreationInputTokens: (session.usage?.cacheCreationInputTokens || 0) + (u.cacheCreationInputTokens || 0),
|
|
257
|
+
cacheReadInputTokens: (session.usage?.cacheReadInputTokens || 0) + (u.cacheReadInputTokens || 0),
|
|
258
|
+
contextWindow: u.contextWindow || session.usage?.contextWindow || 200000,
|
|
259
|
+
contextTokens: u.contextTokens || session.usage?.contextTokens || 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
session.onTurnComplete();
|
|
264
|
+
session.client = null; // 解绑客户端,允许下一个 query 绑定
|
|
265
|
+
session.processing = false;
|
|
266
|
+
this._safeWriteState(session);
|
|
267
|
+
|
|
268
|
+
// 异步处理队列中的下一个请求
|
|
269
|
+
setImmediate(() => this._processQueue(session));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (err?.code === 'ABORT_ERR') return;
|
|
274
|
+
this._send(session.client, { type: 'error', content: err instanceof Error ? err.message : String(err), time: session.clock.next() });
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
277
|
+
}
|
|
76
278
|
|
|
77
279
|
/**
|
|
78
|
-
*
|
|
79
|
-
* 优先直接交付给等待中的迭代器,否则放入缓冲区(最多 8 条)。
|
|
280
|
+
* 尝试处理 session 队列中的下一个查询。
|
|
80
281
|
*/
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
282
|
+
_processQueue(session) {
|
|
283
|
+
if (session.closed) return;
|
|
284
|
+
if (session.queue.length === 0 || session.processing) return;
|
|
285
|
+
|
|
286
|
+
const { client, prompt, id } = session.queue.shift();
|
|
287
|
+
session.client = client;
|
|
288
|
+
session.processing = true;
|
|
289
|
+
session.currentTurnId = generateId('turn');
|
|
290
|
+
|
|
291
|
+
this._send(session.client, {
|
|
292
|
+
type: 'turn_start',
|
|
293
|
+
turn: session.currentTurnId,
|
|
294
|
+
time: session.clock.next(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const sdkMessage = {
|
|
298
|
+
type: 'user',
|
|
299
|
+
message: { role: 'user', content: prompt },
|
|
300
|
+
session_id: session.sessionId || '',
|
|
301
|
+
uuid: generateId('msg'),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
session.enqueueMessage(sdkMessage);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 向一个 WS 客户端发 JSON(安全断开则跳过) */
|
|
308
|
+
_send(client, data) {
|
|
309
|
+
if (client && client.readyState === 1) {
|
|
310
|
+
client.send(JSON.stringify(data));
|
|
89
311
|
}
|
|
90
312
|
}
|
|
91
313
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
314
|
+
/** 持久化 session 状态(写锁防止同名并发写) */
|
|
315
|
+
_safeWriteState(session) {
|
|
316
|
+
const name = session.name;
|
|
317
|
+
// JS 单线程,用简单 flag 防同一 session 的递归写
|
|
318
|
+
writeState(name, createState(name, {
|
|
319
|
+
sessionId: session.sessionId,
|
|
320
|
+
model: session.sdkOptions.model,
|
|
321
|
+
usage: session.usage,
|
|
322
|
+
}));
|
|
98
323
|
}
|
|
99
324
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
325
|
+
/** 取消 idle 定时器 */
|
|
326
|
+
_cancelIdleTimer(name) {
|
|
327
|
+
const timer = this._idleTimers.get(name);
|
|
328
|
+
if (timer) {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
this._idleTimers.delete(name);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 安排 idle 关闭 */
|
|
335
|
+
_scheduleIdleCleanup(name) {
|
|
336
|
+
this._cancelIdleTimer(name);
|
|
337
|
+
this._idleTimers.set(name, setTimeout(() => {
|
|
338
|
+
this.destroy(name, 'idle timeout');
|
|
339
|
+
}, SESSION_IDLE_TIMEOUT_MS));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 从 session 队列中移除指定客户端的所有待处理请求。
|
|
344
|
+
*/
|
|
345
|
+
removeClientFromQueue(session, ws) {
|
|
346
|
+
if (!session || session.closed) return;
|
|
347
|
+
session.queue = session.queue.filter(item => item.client !== ws);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 销毁一个 session。
|
|
352
|
+
*/
|
|
353
|
+
async destroy(name, reason = 'shutdown') {
|
|
354
|
+
const session = this.sessions.get(name);
|
|
355
|
+
if (!session || session.closed) return;
|
|
356
|
+
session.closed = true;
|
|
357
|
+
this._cancelIdleTimer(name);
|
|
358
|
+
|
|
359
|
+
// 关闭 MessageChannel → SDK next() 返回 done
|
|
360
|
+
session.closeChannel();
|
|
361
|
+
|
|
362
|
+
// 中断 SDK 查询
|
|
363
|
+
try {
|
|
364
|
+
await session.response.interrupt();
|
|
365
|
+
} catch { /* ignore */ }
|
|
366
|
+
|
|
367
|
+
// 等待消费循环结束
|
|
368
|
+
try {
|
|
369
|
+
await session.consumerPromise;
|
|
370
|
+
} catch { /* ignore */ }
|
|
371
|
+
|
|
372
|
+
this.sessions.delete(name);
|
|
373
|
+
|
|
374
|
+
// 如果是正常关闭才清理状态文件(crash 留文件便于恢复)
|
|
375
|
+
if (reason !== 'crash') {
|
|
376
|
+
deleteState(name);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 销毁所有 session。
|
|
382
|
+
*/
|
|
383
|
+
async destroyAll(reason = 'shutdown') {
|
|
384
|
+
const names = [...this.sessions.keys()];
|
|
385
|
+
await Promise.allSettled(names.map(name => this.destroy(name, reason)));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// =================================================================
|
|
390
|
+
// 启动函数
|
|
391
|
+
// =================================================================
|
|
105
392
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
393
|
+
/**
|
|
394
|
+
* 启动 WebSocket 持久化服务。
|
|
395
|
+
*/
|
|
396
|
+
export async function startServe(options) {
|
|
397
|
+
const { name = 'default', claudePath, model, cwd, env, port = DEFAULT_PORT } = options;
|
|
109
398
|
|
|
110
|
-
|
|
399
|
+
const machineId = getMachineId();
|
|
400
|
+
const host = hostname();
|
|
401
|
+
const osInfo = `${platform()}/${release()}/${machine()}`;
|
|
402
|
+
|
|
403
|
+
// 服务器级别状态
|
|
404
|
+
const serverState = readState(name);
|
|
405
|
+
const serverSessionId = serverState?.sessionId || null;
|
|
406
|
+
|
|
407
|
+
// 创建 SessionManager
|
|
408
|
+
const sessionManager = new SessionManager({ claudePath, model, cwd, env });
|
|
409
|
+
|
|
410
|
+
// =================================================================
|
|
411
|
+
// WebSocket 服务器
|
|
412
|
+
// =================================================================
|
|
413
|
+
|
|
414
|
+
const wss = new WebSocketServer({ port, host: '127.0.0.1' });
|
|
415
|
+
|
|
416
|
+
// 等待服务器就绪
|
|
417
|
+
await new Promise((resolve, reject) => {
|
|
418
|
+
wss.once('listening', resolve);
|
|
419
|
+
wss.once('error', (err) => {
|
|
420
|
+
if (err.code === 'EADDRINUSE') {
|
|
421
|
+
console.error(`Port ${port} already in use — another nx-ce serve is running`);
|
|
422
|
+
}
|
|
423
|
+
reject(err);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// 写入服务器级状态
|
|
111
428
|
writeState(name, {
|
|
112
429
|
name,
|
|
113
430
|
pid: process.pid,
|
|
114
431
|
startedAt: new Date().toISOString(),
|
|
115
|
-
|
|
116
|
-
|
|
432
|
+
host,
|
|
433
|
+
machineId,
|
|
434
|
+
port,
|
|
435
|
+
lifecycleState: LifecycleState.RUNNING,
|
|
436
|
+
sessionCount: 0,
|
|
117
437
|
});
|
|
118
438
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
439
|
+
// 客户端连接处理
|
|
440
|
+
wss.on('connection', (ws) => {
|
|
441
|
+
// 初始连接消息
|
|
442
|
+
ws.send(JSON.stringify({
|
|
443
|
+
type: 'connected',
|
|
444
|
+
port,
|
|
445
|
+
host,
|
|
446
|
+
machineId,
|
|
447
|
+
serverTime: Date.now(),
|
|
448
|
+
}));
|
|
449
|
+
|
|
450
|
+
ws.on('message', async (raw) => {
|
|
451
|
+
let req;
|
|
452
|
+
try {
|
|
453
|
+
req = JSON.parse(raw.toString());
|
|
454
|
+
} catch {
|
|
455
|
+
ws.send(JSON.stringify({ type: 'error', content: 'invalid JSON' }));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const sessionName = req.session || 'default';
|
|
460
|
+
|
|
461
|
+
switch (req.type) {
|
|
462
|
+
case 'query': {
|
|
463
|
+
if (!req.prompt) {
|
|
464
|
+
ws.send(JSON.stringify({ type: 'error', content: 'query missing prompt' }));
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 获取或创建 session(创建锁保证并发安全)
|
|
469
|
+
let session;
|
|
470
|
+
try {
|
|
471
|
+
session = await sessionManager.getOrCreate(sessionName);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
ws.send(JSON.stringify({ type: 'error', content: `session create failed: ${err.message}` }));
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 入队
|
|
478
|
+
session.queue.push({ client: ws, prompt: req.prompt, id: req.id });
|
|
479
|
+
sessionManager._processQueue(session);
|
|
480
|
+
break;
|
|
144
481
|
}
|
|
145
482
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
writeMessage(process.stdout, { type: 'thinking', content: block.thinking });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
483
|
+
case 'ping':
|
|
484
|
+
ws.send(JSON.stringify({ type: 'pong', serverTime: Date.now() }));
|
|
485
|
+
break;
|
|
486
|
+
|
|
487
|
+
case 'getSkills': {
|
|
488
|
+
const session = sessionManager.sessions.get(sessionName);
|
|
489
|
+
if (session?.metadata) {
|
|
490
|
+
ws.send(JSON.stringify(session.metadata));
|
|
491
|
+
} else {
|
|
492
|
+
ws.send(JSON.stringify({
|
|
493
|
+
type: 'skills',
|
|
494
|
+
skills: [],
|
|
495
|
+
tools: [],
|
|
496
|
+
slashCommands: [],
|
|
497
|
+
agents: [],
|
|
498
|
+
note: 'session not yet initialized',
|
|
499
|
+
}));
|
|
166
500
|
}
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
case 'getStatus': {
|
|
505
|
+
const session = sessionManager.sessions.get(sessionName);
|
|
506
|
+
ws.send(JSON.stringify({
|
|
507
|
+
type: 'status',
|
|
508
|
+
session: sessionName,
|
|
509
|
+
sessionId: session?.sessionId || null,
|
|
510
|
+
isActive: session ? !session.closed : false,
|
|
511
|
+
queueLength: session?.queue?.length || 0,
|
|
512
|
+
processing: session?.processing || false,
|
|
513
|
+
}));
|
|
514
|
+
break;
|
|
167
515
|
}
|
|
168
516
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
517
|
+
case 'closeSession': {
|
|
518
|
+
await sessionManager.destroy(sessionName, 'client request');
|
|
519
|
+
ws.send(JSON.stringify({ type: 'session_closed', session: sessionName }));
|
|
520
|
+
break;
|
|
173
521
|
}
|
|
522
|
+
|
|
523
|
+
case 'listSessions': {
|
|
524
|
+
const sessions = [...sessionManager.sessions.entries()]
|
|
525
|
+
.filter(([_, s]) => !s.closed)
|
|
526
|
+
.map(([name, s]) => ({
|
|
527
|
+
name,
|
|
528
|
+
sessionId: s.sessionId,
|
|
529
|
+
queueLength: s.queue.length,
|
|
530
|
+
processing: s.processing,
|
|
531
|
+
}));
|
|
532
|
+
ws.send(JSON.stringify({ type: 'session_list', sessions }));
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
default:
|
|
537
|
+
ws.send(JSON.stringify({ type: 'error', content: `unknown type: ${req.type}` }));
|
|
174
538
|
}
|
|
175
|
-
}
|
|
176
|
-
if (err?.code === 'ABORT_ERR') return; // 主动中断,非错误
|
|
177
|
-
writeMessage(process.stdout, {
|
|
178
|
-
type: 'error',
|
|
179
|
-
content: err instanceof Error ? err.message : String(err),
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
})();
|
|
539
|
+
});
|
|
183
540
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
541
|
+
// 客户端断开 → 清理引用
|
|
542
|
+
ws.on('close', () => {
|
|
543
|
+
for (const [sName, session] of sessionManager.sessions) {
|
|
544
|
+
if (session.client === ws) {
|
|
545
|
+
session.client = null;
|
|
546
|
+
}
|
|
547
|
+
sessionManager.removeClientFromQueue(session, ws);
|
|
548
|
+
|
|
549
|
+
// 如果没有客户端了,安排 idle 回收
|
|
550
|
+
if (session.client === null && session.queue.length === 0 && !session.closed) {
|
|
551
|
+
sessionManager._scheduleIdleCleanup(sName);
|
|
194
552
|
}
|
|
195
|
-
break; // EOF 或解析错误 → 关闭
|
|
196
553
|
}
|
|
554
|
+
});
|
|
555
|
+
});
|
|
197
556
|
|
|
198
|
-
|
|
557
|
+
// =================================================================
|
|
558
|
+
// 信号处理(优雅关闭)
|
|
559
|
+
// =================================================================
|
|
199
560
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
role: 'user',
|
|
207
|
-
content: req.prompt,
|
|
208
|
-
},
|
|
209
|
-
session_id: currentSessionId || '',
|
|
210
|
-
uuid: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
211
|
-
};
|
|
561
|
+
async function shutdown() {
|
|
562
|
+
// 更新服务器状态
|
|
563
|
+
writeState(name, {
|
|
564
|
+
...readState(name),
|
|
565
|
+
lifecycleState: LifecycleState.STOPPED,
|
|
566
|
+
});
|
|
212
567
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
} else if (req.type === 'getSkills') {
|
|
218
|
-
// Go 端主动拉取 skill/工具/命令 列表(可重复查询)
|
|
219
|
-
writeMessage(process.stdout, sessionMetadata || {
|
|
220
|
-
type: 'skills',
|
|
221
|
-
skills: [],
|
|
222
|
-
tools: [],
|
|
223
|
-
slashCommands: [],
|
|
224
|
-
agents: [],
|
|
225
|
-
note: 'session not yet initialized',
|
|
226
|
-
});
|
|
568
|
+
// 通知所有 WS 客户端
|
|
569
|
+
wss.clients.forEach((client) => {
|
|
570
|
+
if (client.readyState === 1) {
|
|
571
|
+
client.close(1001, 'server shutting down');
|
|
227
572
|
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
try {
|
|
236
|
-
await response.interrupt();
|
|
237
|
-
} catch { /* 忽略中断错误 */ }
|
|
238
|
-
await consumerPromise;
|
|
573
|
+
});
|
|
574
|
+
wss.close();
|
|
575
|
+
|
|
576
|
+
// 关闭所有 session
|
|
577
|
+
await sessionManager.destroyAll('shutdown');
|
|
578
|
+
|
|
579
|
+
// 删除服务端状态文件
|
|
239
580
|
deleteState(name);
|
|
581
|
+
|
|
582
|
+
process.exit(0);
|
|
240
583
|
}
|
|
584
|
+
|
|
585
|
+
process.on('SIGINT', shutdown);
|
|
586
|
+
process.on('SIGTERM', shutdown);
|
|
587
|
+
|
|
588
|
+
// =================================================================
|
|
589
|
+
// 返回
|
|
590
|
+
// =================================================================
|
|
591
|
+
|
|
592
|
+
const info = { port, name };
|
|
593
|
+
console.error(`nx-ce serve ws://127.0.0.1:${port} [${name}]`);
|
|
594
|
+
|
|
595
|
+
return info;
|
|
241
596
|
}
|