nx-ce 0.1.5 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-ce",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Claude Engine — SDK adapter layer for native messaging host. Bridges @anthropic-ai/claude-agent-sdk calls over a length-prefixed JSON protocol.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/serve.js CHANGED
@@ -5,6 +5,9 @@
5
5
  * 每个会话(session)拥有独立的 agentQuery()、MessageChannel 和状态文件,
6
6
  * 天然并行,互不阻塞。
7
7
  *
8
+ * 会话标识 = name:cwd(同一 name 不同 cwd 视为不同会话)。
9
+ * 客户端可通过 query 消息的 cwd 字段指定工作目录。
10
+ *
8
11
  * 竞态保护:
9
12
  * - session 创建:pendingCreates Map 防止重复创建
10
13
  * - client 绑定:SDK 回复只写 session.client,不走 broadcast
@@ -24,6 +27,31 @@ const DEFAULT_PORT = 3100;
24
27
  /** 空闲 session 超时(毫秒),超过此时间无客户端则自动关闭 */
25
28
  const SESSION_IDLE_TIMEOUT_MS = 300_000; // 5 分钟
26
29
 
30
+ // =================================================================
31
+ // 工具函数
32
+ // =================================================================
33
+
34
+ /**
35
+ * 生成 session 内部标识 key。
36
+ * 同一 name 不同 cwd 产生不同 key,各自独立 agentQuery。
37
+ *
38
+ * @param {string} name - 会话名称(来自客户端)
39
+ * @param {string} [cwd] - 工作目录
40
+ * @returns {string} 内部 key
41
+ */
42
+ function sessionKey(name, cwd) {
43
+ if (cwd) return `${name}:${cwd}`;
44
+ return name;
45
+ }
46
+
47
+ /**
48
+ * 从 sessionKey 中提取原始 name(用于 closeSession 匹配)。
49
+ */
50
+ function baseName(key) {
51
+ const idx = key.indexOf(':');
52
+ return idx === -1 ? key : key.slice(0, idx);
53
+ }
54
+
27
55
  // =================================================================
28
56
  // SessionManager — 管理多个独立 SDK 会话
29
57
  // =================================================================
@@ -40,56 +68,58 @@ class SessionManager {
40
68
 
41
69
  /** 清理定时器 */
42
70
  this._idleTimers = new Map();
43
-
44
- /** 会话状态文件写锁 */
45
- this._writeLocks = new Map();
46
71
  }
47
72
 
48
73
  /**
49
74
  * 获取或创建一个 session。
50
- * 如果另一个协程正在创建同名 session,则等待其完成。
75
+ * (name, cwd) 为唯一标识。
76
+ * 如果另一个协程正在创建同 key session,则等待其完成。
51
77
  *
52
- * @param {string} name - session 名称(每个客户端/标签页唯一)
78
+ * @param {string} name - 会话名称
79
+ * @param {string} [cwd] - 工作目录(可选,默认服务器级 cwd)
53
80
  * @returns {Promise<Session>}
54
81
  */
55
- async getOrCreate(name) {
82
+ async getOrCreate(name, cwd) {
83
+ const key = sessionKey(name, cwd);
84
+
56
85
  // 已有活跃 session → 直接返回
57
- const existing = this.sessions.get(name);
86
+ const existing = this.sessions.get(key);
58
87
  if (existing && !existing.closed) {
59
- // 取消 idle 定时器(客户端回来了)
60
- this._cancelIdleTimer(name);
88
+ this._cancelIdleTimer(key);
61
89
  return existing;
62
90
  }
63
91
 
64
92
  // 正在被另一个协程创建 → 等它
65
- if (this._pendingCreates.has(name)) {
66
- return this._pendingCreates.get(name);
93
+ if (this._pendingCreates.has(key)) {
94
+ return this._pendingCreates.get(key);
67
95
  }
68
96
 
69
97
  // 创建锁 + 创建
70
- const promise = this._createSession(name);
71
- this._pendingCreates.set(name, promise);
98
+ const promise = this._createSession(name, key, cwd);
99
+ this._pendingCreates.set(key, promise);
72
100
 
73
101
  try {
74
102
  return await promise;
75
103
  } finally {
76
- this._pendingCreates.delete(name);
104
+ this._pendingCreates.delete(key);
77
105
  }
78
106
  }
79
107
 
80
108
  /**
81
109
  * 创建内部 session 结构。
82
- * 注意:JS 是单线程 event loop,此函数不会被并发调用(pendingCreates 保证)。
83
110
  */
84
- async _createSession(name) {
85
- const { claudePath, model, cwd, env } = this.serverOptions;
111
+ async _createSession(name, key, cwd) {
112
+ const { claudePath, model, env } = this.serverOptions;
113
+
114
+ // session 用自己的 cwd(优先客户端传入,fallback 到服务器级)
115
+ const actualCwd = cwd || this.serverOptions.cwd || process.cwd();
86
116
 
87
- // 检查是否有可恢复的会话状态
88
- const existingState = readState(name);
117
+ // 检查是否有可恢复的会话状态(按 key 存储,实现不同目录独立状态)
118
+ const existingState = readState(key);
89
119
 
90
120
  // 组装 SDK 选项
91
121
  const sdkOptions = {
92
- cwd: cwd || process.cwd(),
122
+ cwd: actualCwd,
93
123
  model: model || 'claude-sonnet-4-6',
94
124
  pathToClaudeCodeExecutable: claudePath,
95
125
  permissionMode: 'bypassPermissions',
@@ -140,9 +170,10 @@ class SessionManager {
140
170
  // 启动 SDK 持久化查询
141
171
  const response = agentQuery({ prompt: messageChannel, options: sdkOptions });
142
172
 
143
- /** @type {Session} */
144
173
  const session = {
145
- name,
174
+ key, // 内部标识:name:cwd
175
+ name, // 客户端名称
176
+ cwd: actualCwd, // session 自己的工作目录
146
177
  messageChannel,
147
178
  enqueueMessage,
148
179
  onTurnComplete,
@@ -161,22 +192,19 @@ class SessionManager {
161
192
  existingState,
162
193
 
163
194
  // 客户端状态
164
- client: null, // 当前绑定的 WebSocket 客户端
165
- queue: [], // 待处理查询 FIFO
166
- turnActive: false, // SDK 是否正在处理
195
+ client: null,
196
+ queue: [],
167
197
  currentTurnId: null,
168
198
  processing: false,
169
199
 
170
200
  // 元数据
171
201
  sessionId: existingState?.sessionId || null,
172
- metadata: null, // init 消息中的 skills/tools 等
202
+ metadata: null,
173
203
  clock: new MonotonicClock(),
174
204
  closed: false,
175
205
 
176
- // 消费 Promise(用于等待关闭)
177
206
  consumerPromise: null,
178
207
 
179
- // usage 追踪
180
208
  usage: existingState?.usage || {
181
209
  inputTokens: 0,
182
210
  outputTokens: 0,
@@ -190,9 +218,9 @@ class SessionManager {
190
218
  // 后台消费 SDK 输出
191
219
  session.consumerPromise = this._startConsumer(session);
192
220
 
193
- this.sessions.set(name, session);
221
+ this.sessions.set(key, session);
194
222
 
195
- // 持久化初始状态
223
+ // 持久化初始状态(使用 key 做文件名,不同 cwd 独立文件)
196
224
  this._safeWriteState(session);
197
225
 
198
226
  return session;
@@ -200,13 +228,11 @@ class SessionManager {
200
228
 
201
229
  /**
202
230
  * 后台消费循环 — 每个 session 独立。
203
- * SDK 回复只会写入 session.client(绑定的 WS 客户端)。
204
231
  */
205
232
  _startConsumer(session) {
206
233
  return (async () => {
207
234
  try {
208
235
  for await (const message of session.response) {
209
- // init 消息 → 捕获元数据
210
236
  if (message.type === 'system' && message.subtype === 'init') {
211
237
  session.sessionId = message.session_id;
212
238
  session.metadata = {
@@ -217,15 +243,13 @@ class SessionManager {
217
243
  tools: message.tools || [],
218
244
  slashCommands: message.slash_commands || [],
219
245
  agents: message.agents || [],
246
+ cwd: session.cwd,
220
247
  time: session.clock.next(),
221
248
  };
222
249
  this._safeWriteState(session);
223
-
224
- // 推给当前绑定的客户端
225
250
  this._send(session.client, session.metadata);
226
251
  }
227
252
 
228
- // 助手消息 → 分块转发
229
253
  if (message.type === 'assistant' && message.message?.content) {
230
254
  const content = message.message.content;
231
255
  if (typeof content === 'string') {
@@ -243,11 +267,9 @@ class SessionManager {
243
267
  }
244
268
  }
245
269
 
246
- // result → 回合结束
247
270
  if (message.type === 'result') {
248
271
  this._send(session.client, { type: 'done', sessionId: session.sessionId, time: session.clock.next() });
249
272
 
250
- // usage 累积
251
273
  if (message.usage) {
252
274
  const u = message.usage;
253
275
  session.usage = {
@@ -261,11 +283,10 @@ class SessionManager {
261
283
  }
262
284
 
263
285
  session.onTurnComplete();
264
- session.client = null; // 解绑客户端,允许下一个 query 绑定
286
+ session.client = null;
265
287
  session.processing = false;
266
288
  this._safeWriteState(session);
267
289
 
268
- // 异步处理队列中的下一个请求
269
290
  setImmediate(() => this._processQueue(session));
270
291
  }
271
292
  }
@@ -304,44 +325,38 @@ class SessionManager {
304
325
  session.enqueueMessage(sdkMessage);
305
326
  }
306
327
 
307
- /** 向一个 WS 客户端发 JSON(安全断开则跳过) */
328
+ /** 向一个 WS 客户端发 JSON */
308
329
  _send(client, data) {
309
330
  if (client && client.readyState === 1) {
310
331
  client.send(JSON.stringify(data));
311
332
  }
312
333
  }
313
334
 
314
- /** 持久化 session 状态(写锁防止同名并发写) */
335
+ /** 持久化 session 状态(按 key 为文件名) */
315
336
  _safeWriteState(session) {
316
- const name = session.name;
317
- // JS 单线程,用简单 flag 防同一 session 的递归写
318
- writeState(name, createState(name, {
337
+ writeState(session.key, createState(session.key, {
319
338
  sessionId: session.sessionId,
320
339
  model: session.sdkOptions.model,
340
+ cwd: session.cwd,
321
341
  usage: session.usage,
322
342
  }));
323
343
  }
324
344
 
325
- /** 取消 idle 定时器 */
326
- _cancelIdleTimer(name) {
327
- const timer = this._idleTimers.get(name);
345
+ _cancelIdleTimer(key) {
346
+ const timer = this._idleTimers.get(key);
328
347
  if (timer) {
329
348
  clearTimeout(timer);
330
- this._idleTimers.delete(name);
349
+ this._idleTimers.delete(key);
331
350
  }
332
351
  }
333
352
 
334
- /** 安排 idle 关闭 */
335
- _scheduleIdleCleanup(name) {
336
- this._cancelIdleTimer(name);
337
- this._idleTimers.set(name, setTimeout(() => {
338
- this.destroy(name, 'idle timeout');
353
+ _scheduleIdleCleanup(key) {
354
+ this._cancelIdleTimer(key);
355
+ this._idleTimers.set(key, setTimeout(() => {
356
+ this.destroy(key, 'idle timeout');
339
357
  }, SESSION_IDLE_TIMEOUT_MS));
340
358
  }
341
359
 
342
- /**
343
- * 从 session 队列中移除指定客户端的所有待处理请求。
344
- */
345
360
  removeClientFromQueue(session, ws) {
346
361
  if (!session || session.closed) return;
347
362
  session.queue = session.queue.filter(item => item.client !== ws);
@@ -349,40 +364,41 @@ class SessionManager {
349
364
 
350
365
  /**
351
366
  * 销毁一个 session。
367
+ * @param {string} key - 内部 key(name:cwd)
352
368
  */
353
- async destroy(name, reason = 'shutdown') {
354
- const session = this.sessions.get(name);
369
+ async destroy(key, reason = 'shutdown') {
370
+ const session = this.sessions.get(key);
355
371
  if (!session || session.closed) return;
356
372
  session.closed = true;
357
- this._cancelIdleTimer(name);
373
+ this._cancelIdleTimer(key);
358
374
 
359
- // 关闭 MessageChannel → SDK next() 返回 done
360
375
  session.closeChannel();
361
376
 
362
- // 中断 SDK 查询
363
- try {
364
- await session.response.interrupt();
365
- } catch { /* ignore */ }
366
-
367
- // 等待消费循环结束
368
- try {
369
- await session.consumerPromise;
370
- } catch { /* ignore */ }
377
+ try { await session.response.interrupt(); } catch { /* ignore */ }
378
+ try { await session.consumerPromise; } catch { /* ignore */ }
371
379
 
372
- this.sessions.delete(name);
380
+ this.sessions.delete(key);
373
381
 
374
- // 如果是正常关闭才清理状态文件(crash 留文件便于恢复)
375
382
  if (reason !== 'crash') {
376
- deleteState(name);
383
+ deleteState(key);
377
384
  }
378
385
  }
379
386
 
387
+ /**
388
+ * 按客户端 name 销毁匹配的所有 session(包括不同 cwd)。
389
+ * @param {string} name - 客户端传入的 session 名称
390
+ */
391
+ async destroyByName(name) {
392
+ const keys = [...this.sessions.keys()].filter(k => baseName(k) === name);
393
+ await Promise.allSettled(keys.map(k => this.destroy(k, 'client request')));
394
+ }
395
+
380
396
  /**
381
397
  * 销毁所有 session。
382
398
  */
383
399
  async destroyAll(reason = 'shutdown') {
384
- const names = [...this.sessions.keys()];
385
- await Promise.allSettled(names.map(name => this.destroy(name, reason)));
400
+ const keys = [...this.sessions.keys()];
401
+ await Promise.allSettled(keys.map(k => this.destroy(k, reason)));
386
402
  }
387
403
  }
388
404
 
@@ -400,11 +416,10 @@ export async function startServe(options) {
400
416
  const host = hostname();
401
417
  const osInfo = `${platform()}/${release()}/${machine()}`;
402
418
 
403
- // 服务器级别状态
404
419
  const serverState = readState(name);
405
420
  const serverSessionId = serverState?.sessionId || null;
406
421
 
407
- // 创建 SessionManager
422
+ // 创建 SessionManager(cwd 为服务器级默认,session 可覆盖)
408
423
  const sessionManager = new SessionManager({ claudePath, model, cwd, env });
409
424
 
410
425
  // =================================================================
@@ -413,7 +428,6 @@ export async function startServe(options) {
413
428
 
414
429
  const wss = new WebSocketServer({ port, host: '127.0.0.1' });
415
430
 
416
- // 等待服务器就绪
417
431
  await new Promise((resolve, reject) => {
418
432
  wss.once('listening', resolve);
419
433
  wss.once('error', (err) => {
@@ -424,7 +438,6 @@ export async function startServe(options) {
424
438
  });
425
439
  });
426
440
 
427
- // 写入服务器级状态
428
441
  writeState(name, {
429
442
  name,
430
443
  pid: process.pid,
@@ -438,7 +451,6 @@ export async function startServe(options) {
438
451
 
439
452
  // 客户端连接处理
440
453
  wss.on('connection', (ws) => {
441
- // 初始连接消息
442
454
  ws.send(JSON.stringify({
443
455
  type: 'connected',
444
456
  port,
@@ -465,16 +477,16 @@ export async function startServe(options) {
465
477
  break;
466
478
  }
467
479
 
468
- // 获取或创建 session(创建锁保证并发安全)
480
+ // 支持每个 query 指定自己的工作目录
481
+ // 同一 session name + 不同 cwd = 不同 SDK 会话
469
482
  let session;
470
483
  try {
471
- session = await sessionManager.getOrCreate(sessionName);
484
+ session = await sessionManager.getOrCreate(sessionName, req.cwd);
472
485
  } catch (err) {
473
486
  ws.send(JSON.stringify({ type: 'error', content: `session create failed: ${err.message}` }));
474
487
  break;
475
488
  }
476
489
 
477
- // 入队
478
490
  session.queue.push({ client: ws, prompt: req.prompt, id: req.id });
479
491
  sessionManager._processQueue(session);
480
492
  break;
@@ -485,7 +497,9 @@ export async function startServe(options) {
485
497
  break;
486
498
 
487
499
  case 'getSkills': {
488
- const session = sessionManager.sessions.get(sessionName);
500
+ // (name, cwd) 查 session
501
+ const key = sessionKey(sessionName, req.cwd);
502
+ const session = sessionManager.sessions.get(key);
489
503
  if (session?.metadata) {
490
504
  ws.send(JSON.stringify(session.metadata));
491
505
  } else {
@@ -502,10 +516,12 @@ export async function startServe(options) {
502
516
  }
503
517
 
504
518
  case 'getStatus': {
505
- const session = sessionManager.sessions.get(sessionName);
519
+ const key = sessionKey(sessionName, req.cwd);
520
+ const session = sessionManager.sessions.get(key);
506
521
  ws.send(JSON.stringify({
507
522
  type: 'status',
508
523
  session: sessionName,
524
+ cwd: req.cwd || cwd || process.cwd(),
509
525
  sessionId: session?.sessionId || null,
510
526
  isActive: session ? !session.closed : false,
511
527
  queueLength: session?.queue?.length || 0,
@@ -515,16 +531,26 @@ export async function startServe(options) {
515
531
  }
516
532
 
517
533
  case 'closeSession': {
518
- await sessionManager.destroy(sessionName, 'client request');
519
- ws.send(JSON.stringify({ type: 'session_closed', session: sessionName }));
534
+ if (req.cwd) {
535
+ // 精确关闭:name + cwd
536
+ const key = sessionKey(sessionName, req.cwd);
537
+ await sessionManager.destroy(key, 'client request');
538
+ ws.send(JSON.stringify({ type: 'session_closed', session: sessionName, cwd: req.cwd }));
539
+ } else {
540
+ // 关闭该 name 下所有 cwd 变体
541
+ await sessionManager.destroyByName(sessionName);
542
+ ws.send(JSON.stringify({ type: 'session_closed', session: sessionName }));
543
+ }
520
544
  break;
521
545
  }
522
546
 
523
547
  case 'listSessions': {
524
548
  const sessions = [...sessionManager.sessions.entries()]
525
549
  .filter(([_, s]) => !s.closed)
526
- .map(([name, s]) => ({
527
- name,
550
+ .map(([key, s]) => ({
551
+ name: s.name,
552
+ key,
553
+ cwd: s.cwd,
528
554
  sessionId: s.sessionId,
529
555
  queueLength: s.queue.length,
530
556
  processing: s.processing,
@@ -540,15 +566,14 @@ export async function startServe(options) {
540
566
 
541
567
  // 客户端断开 → 清理引用
542
568
  ws.on('close', () => {
543
- for (const [sName, session] of sessionManager.sessions) {
569
+ for (const [sKey, session] of sessionManager.sessions) {
544
570
  if (session.client === ws) {
545
571
  session.client = null;
546
572
  }
547
573
  sessionManager.removeClientFromQueue(session, ws);
548
574
 
549
- // 如果没有客户端了,安排 idle 回收
550
575
  if (session.client === null && session.queue.length === 0 && !session.closed) {
551
- sessionManager._scheduleIdleCleanup(sName);
576
+ sessionManager._scheduleIdleCleanup(sKey);
552
577
  }
553
578
  }
554
579
  });
@@ -559,13 +584,11 @@ export async function startServe(options) {
559
584
  // =================================================================
560
585
 
561
586
  async function shutdown() {
562
- // 更新服务器状态
563
587
  writeState(name, {
564
588
  ...readState(name),
565
589
  lifecycleState: LifecycleState.STOPPED,
566
590
  });
567
591
 
568
- // 通知所有 WS 客户端
569
592
  wss.clients.forEach((client) => {
570
593
  if (client.readyState === 1) {
571
594
  client.close(1001, 'server shutting down');
@@ -573,10 +596,7 @@ export async function startServe(options) {
573
596
  });
574
597
  wss.close();
575
598
 
576
- // 关闭所有 session
577
599
  await sessionManager.destroyAll('shutdown');
578
-
579
- // 删除服务端状态文件
580
600
  deleteState(name);
581
601
 
582
602
  process.exit(0);
@@ -585,10 +605,6 @@ export async function startServe(options) {
585
605
  process.on('SIGINT', shutdown);
586
606
  process.on('SIGTERM', shutdown);
587
607
 
588
- // =================================================================
589
- // 返回
590
- // =================================================================
591
-
592
608
  const info = { port, name };
593
609
  console.error(`nx-ce serve ws://127.0.0.1:${port} [${name}]`);
594
610
 
@@ -132,11 +132,13 @@ export function listStates() {
132
132
 
133
133
  /**
134
134
  * 将实例名称清理为安全的文件名。
135
- * 移除非字母数字的字符,替换为下划线。
135
+ * 保留字母、数字、点、下划线、连字符、冒号(转为 ~)。
136
+ * 冒号转为 ~ 是为了支持 "name:cwd" 格式的内部 key。
136
137
  *
137
138
  * @param {string} name - 原始实例名称
138
139
  * @returns {string} 安全的文件名(带 .json 后缀)
139
140
  */
140
141
  function sanitize(name) {
141
- return `${String(name).replace(/[^a-zA-Z0-9._-]/g, '_')}.json`;
142
+ const safe = String(name).replace(/[^a-zA-Z0-9._~-]/g, '_').replace(/:/g, '~');
143
+ return `${safe}.json`;
142
144
  }