nx-ce 0.1.5 → 0.1.7
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 +93 -67
- package/package.json +1 -1
- package/src/serve.js +116 -100
- package/src/session-store.js +4 -2
package/README.md
CHANGED
|
@@ -41,12 +41,13 @@ nx-ce query "用中文回答:1+1=?" --model claude-haiku-4-5
|
|
|
41
41
|
# Start WebSocket server (persistent, multi-session)
|
|
42
42
|
nx-ce serve --port 3100
|
|
43
43
|
|
|
44
|
-
# In another terminal,
|
|
44
|
+
# In another terminal, run tests
|
|
45
|
+
node test/serve-test.mjs
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
---
|
|
48
49
|
|
|
49
|
-
## `nx-ce query` — One-
|
|
50
|
+
## `nx-ce query` — One-Shot Cold-Start Query / 一次性冷启动查询
|
|
50
51
|
|
|
51
52
|
```bash
|
|
52
53
|
nx-ce query "解释这段代码" --model claude-sonnet-4-6
|
|
@@ -80,9 +81,9 @@ nx-ce query "Analyze" --skill all
|
|
|
80
81
|
|
|
81
82
|
## `nx-ce serve` — WebSocket Multi-Session Server / WebSocket 多会话服务器
|
|
82
83
|
|
|
83
|
-
**Single process. Multiple concurrent sessions.
|
|
84
|
+
**Single process. Multiple concurrent sessions. Each session has its own cwd.**
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
单例进程。多会话隔离。每个会话可指定自己的工作目录。
|
|
86
87
|
|
|
87
88
|
```bash
|
|
88
89
|
nx-ce serve # default port 3100
|
|
@@ -96,7 +97,7 @@ nx-ce serve --name "main" --port 3100 --cwd "D:/project"
|
|
|
96
97
|
| `--port <port>` | WebSocket port (default `3100`) / 端口 |
|
|
97
98
|
| `--model <id>` | Model override / 模型 ID |
|
|
98
99
|
| `--claude-path <path>` | Path to Claude CLI / CLI 路径 |
|
|
99
|
-
| `--cwd <path>` |
|
|
100
|
+
| `--cwd <path>` | Default working directory / 默认工作目录 |
|
|
100
101
|
| `--env "KEY=val,..."` | Extra env vars / 额外环境变量 |
|
|
101
102
|
|
|
102
103
|
> WebSocket address: `ws://127.0.0.1:3100` (localhost only)
|
|
@@ -105,7 +106,58 @@ nx-ce serve --name "main" --port 3100 --cwd "D:/project"
|
|
|
105
106
|
|
|
106
107
|
```bash
|
|
107
108
|
nx-ce serve --port 3100 # first → OK
|
|
108
|
-
nx-ce serve --port 3100 # second → Port
|
|
109
|
+
nx-ce serve --port 3100 # second → Port already in use — another instance is running
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Multi-Session / 多会话管理
|
|
115
|
+
|
|
116
|
+
### Auto-create on first query / 首次 query 自动创建
|
|
117
|
+
|
|
118
|
+
Session 是**隐式创建**的。第一次发 `query` 时自动创建 SDK 会话,之后同名的 query 续接上下文:
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
// 新会话 "proj-a",工作目录 D:/project-a
|
|
122
|
+
→ { "type": "query", "session": "proj-a", "cwd": "D:/project-a", "prompt": "分析目录结构" }
|
|
123
|
+
|
|
124
|
+
// 新会话 "proj-b",工作目录 D:/project-b(不同的 session 名 = 不同的 agentQuery)
|
|
125
|
+
→ { "type": "query", "session": "proj-b", "cwd": "D:/project-b", "prompt": "分析目录结构" }
|
|
126
|
+
|
|
127
|
+
// 同一 session 名 + 不同 cwd → 也是独立的 agentQuery
|
|
128
|
+
→ { "type": "query", "session": "proj-a", "cwd": "D:/project-a/src", "prompt": "分析 src" }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
内部标识 key 格式为 `{name}:{cwd}`:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
proj-a:D~project-a → SDK 会话 A
|
|
135
|
+
proj-b:D~project-b → SDK 会话 B
|
|
136
|
+
proj-a:D~project-a~src → SDK 会话 C
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Close session / 关闭会话
|
|
140
|
+
|
|
141
|
+
```javascript
|
|
142
|
+
// 关闭精确会话(指定 cwd)
|
|
143
|
+
→ { "type": "closeSession", "session": "proj-a", "cwd": "D:/project-a" }
|
|
144
|
+
|
|
145
|
+
// 关闭该 name 下所有 cwd 变体
|
|
146
|
+
→ { "type": "closeSession", "session": "proj-a" }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Idle auto-reclaim / 空闲自动回收
|
|
150
|
+
|
|
151
|
+
客户端断开连接 5 分钟后,session 自动销毁并清理状态文件。
|
|
152
|
+
|
|
153
|
+
### List sessions / 查看会话
|
|
154
|
+
|
|
155
|
+
```javascript
|
|
156
|
+
→ { "type": "listSessions" }
|
|
157
|
+
← { "type": "session_list", "sessions": [
|
|
158
|
+
{ "name": "proj-a", "cwd": "D:/project-a", "sessionId": "sess_xxx", "processing": false },
|
|
159
|
+
{ "name": "proj-b", "cwd": "D:/project-b", "sessionId": "sess_yyy", "processing": true }
|
|
160
|
+
]}
|
|
109
161
|
```
|
|
110
162
|
|
|
111
163
|
---
|
|
@@ -118,21 +170,22 @@ Server: `ws://127.0.0.1:PORT`. All messages are JSON (no length prefix).
|
|
|
118
170
|
|
|
119
171
|
| type | Fields / 字段 | Description / 说明 |
|
|
120
172
|
|------|---------------|-------------------|
|
|
121
|
-
| `query` | `prompt: string`, `session?: string`, `id?: string` | Submit a query / 发起查询 |
|
|
173
|
+
| `query` | `prompt: string`, `session?: string`, `cwd?: string`, `id?: string` | Submit a query / 发起查询 |
|
|
122
174
|
| `ping` | — | Heartbeat / 心跳 |
|
|
123
|
-
| `getSkills` | `session?: string` | Fetch skills/tools/agents / 拉取元数据 |
|
|
124
|
-
| `getStatus` | `session?: string` | Query session status / 查询状态 |
|
|
125
|
-
| `closeSession` | `session: string` | Close
|
|
175
|
+
| `getSkills` | `session?: string`, `cwd?: string` | Fetch skills/tools/agents / 拉取元数据 |
|
|
176
|
+
| `getStatus` | `session?: string`, `cwd?: string` | Query session status / 查询状态 |
|
|
177
|
+
| `closeSession` | `session: string`, `cwd?: string` | Close session(s) / 关闭会话 |
|
|
126
178
|
| `listSessions` | — | List all active sessions / 列出会话 |
|
|
127
179
|
|
|
128
|
-
`session` defaults to `"default"
|
|
180
|
+
`session` defaults to `"default"`.
|
|
129
181
|
|
|
130
182
|
```json
|
|
131
|
-
→ { "type": "query",
|
|
183
|
+
→ { "type": "query", "session": "proj-a", "cwd": "D:/project-a", "prompt": "分析" }
|
|
132
184
|
→ { "type": "ping" }
|
|
133
|
-
→ { "type": "getSkills",
|
|
134
|
-
→ { "type": "getStatus",
|
|
135
|
-
→ { "type": "closeSession",
|
|
185
|
+
→ { "type": "getSkills", "session": "proj-a" }
|
|
186
|
+
→ { "type": "getStatus", "session": "proj-a", "cwd": "D:/project-a" }
|
|
187
|
+
→ { "type": "closeSession", "session": "proj-a", "cwd": "D:/project-a" }
|
|
188
|
+
→ { "type": "closeSession", "session": "proj-a" }
|
|
136
189
|
→ { "type": "listSessions" }
|
|
137
190
|
```
|
|
138
191
|
|
|
@@ -142,7 +195,7 @@ Server: `ws://127.0.0.1:PORT`. All messages are JSON (no length prefix).
|
|
|
142
195
|
|
|
143
196
|
```json
|
|
144
197
|
← { "type": "connected", "port": 3100, "host": "MY-PC",
|
|
145
|
-
"machineId": "744e51b9
|
|
198
|
+
"machineId": "744e51b9-...", "serverTime": 1780736149028 }
|
|
146
199
|
```
|
|
147
200
|
|
|
148
201
|
**Session init (auto-push on first query per session) / 会话初始化(自动推送):**
|
|
@@ -161,7 +214,7 @@ Server: `ws://127.0.0.1:PORT`. All messages are JSON (no length prefix).
|
|
|
161
214
|
← { "type": "turn_start", "turn": "turn_xxx", "time": ... }
|
|
162
215
|
← { "type": "text", "content": "这是一段回复...", "time": ... }
|
|
163
216
|
← { "type": "thinking", "content": "模型思考过程...", "time": ... }
|
|
164
|
-
← { "type": "tool_use", "name": "readFile", "input": {...}
|
|
217
|
+
← { "type": "tool_use", "name": "readFile", "input": {...} }
|
|
165
218
|
← { "type": "done", "sessionId": "sess_xxx", "time": ... }
|
|
166
219
|
```
|
|
167
220
|
|
|
@@ -170,19 +223,19 @@ Server: `ws://127.0.0.1:PORT`. All messages are JSON (no length prefix).
|
|
|
170
223
|
```json
|
|
171
224
|
← { "type": "pong", "sessionId": "sess_xxx", "serverTime": ... }
|
|
172
225
|
← { "type": "skills", "skills": [...], "tools": [...], ... }
|
|
173
|
-
← { "type": "status", "session": "
|
|
174
|
-
← { "type": "session_list", "sessions": [{ "name": "
|
|
175
|
-
← { "type": "session_closed","session": "
|
|
226
|
+
← { "type": "status", "session": "proj-a", "cwd": "D:/project-a", "sessionId": "...", "isActive": true }
|
|
227
|
+
← { "type": "session_list", "sessions": [{ "name":"proj-a", "cwd":"D:/project-a", ... }, ...] }
|
|
228
|
+
← { "type": "session_closed","session": "proj-a", "cwd": "D:/project-a" }
|
|
176
229
|
← { "type": "error", "content": "error message" }
|
|
177
230
|
```
|
|
178
231
|
|
|
179
232
|
### Full exchange example / 完整示例
|
|
180
233
|
|
|
181
234
|
```
|
|
182
|
-
→ { "type":"query", "session":"
|
|
235
|
+
→ { "type":"query", "session":"proj-a", "cwd":"D:/project-a", "prompt":"Hello" }
|
|
183
236
|
← { "type":"turn_start", "turn":"turn_xxx", "time":... }
|
|
184
|
-
← { "type":"text",
|
|
185
|
-
← { "type":"done",
|
|
237
|
+
← { "type":"text", "content":"Hello! How can I help?" }
|
|
238
|
+
← { "type":"done", "sessionId":"sess_abc", "time":... }
|
|
186
239
|
|
|
187
240
|
→ { "type":"ping" }
|
|
188
241
|
← { "type":"pong", "sessionId":"sess_abc", "serverTime":... }
|
|
@@ -190,51 +243,41 @@ Server: `ws://127.0.0.1:PORT`. All messages are JSON (no length prefix).
|
|
|
190
243
|
|
|
191
244
|
---
|
|
192
245
|
|
|
193
|
-
##
|
|
246
|
+
## Architecture / 架构
|
|
194
247
|
|
|
195
248
|
```
|
|
196
249
|
nx-ce serve (single Node.js process)
|
|
197
|
-
|
|
250
|
+
┌──────────────────────────────────────────────────────────┐
|
|
198
251
|
│ WebSocket Server (127.0.0.1:3100) │
|
|
199
252
|
│ │
|
|
200
253
|
│ SessionManager │
|
|
201
254
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
202
|
-
│ │ "
|
|
203
|
-
│ │ "
|
|
204
|
-
│ │ "
|
|
255
|
+
│ │ "proj-a:D~/project-a" → agentQuery(cwd: project-a) │ │
|
|
256
|
+
│ │ "proj-b:D~/project-b" → agentQuery(cwd: project-b) │ │
|
|
257
|
+
│ │ "proj-a:D~/other" → agentQuery(cwd: other) │ │
|
|
205
258
|
│ └──────────────────────┬──────────────────────────────┘ │
|
|
206
259
|
│ spawn each | (SDK manages CLI processes) │
|
|
207
|
-
│
|
|
260
|
+
│ Claude CLI ──────────┴── Claude CLI ──── Claude CLI │
|
|
208
261
|
└───────────────────────────────────────────────────────────┘
|
|
209
262
|
```
|
|
210
263
|
|
|
264
|
+
### Session identity / 会话标识
|
|
265
|
+
|
|
266
|
+
Session key = `{name}:{cwd}`. Same name + different cwd = different SDK session.
|
|
267
|
+
Each session has its own `agentQuery()`, `MessageChannel`, `MonotonicClock`, and state file.
|
|
268
|
+
|
|
211
269
|
### Concurrency guarantees / 竞态保护
|
|
212
270
|
|
|
213
271
|
| Race / 竞态 | Solution / 方案 |
|
|
214
272
|
|-------------|----------------|
|
|
215
273
|
| Concurrent session creation | `_pendingCreates` Map deduplicates in-flight creation promises |
|
|
216
274
|
| SDK response routing | Each session has independent `for await` loop, writes only to `session.client` |
|
|
217
|
-
| State file overwrite | Per-session files (`{name}.json`)
|
|
218
|
-
| Message ordering | Per-session `MonotonicClock` ensures strict
|
|
275
|
+
| State file overwrite | Per-session files (`{name~cwd}.json`) |
|
|
276
|
+
| Message ordering | Per-session `MonotonicClock` ensures strict ordering |
|
|
219
277
|
| Client disconnect cleanup | Null client ref + clear queue + 5-min idle timeout auto-destroy |
|
|
220
278
|
|
|
221
279
|
---
|
|
222
280
|
|
|
223
|
-
## `nx-ce status` — Instance Status / 查看实例状态
|
|
224
|
-
|
|
225
|
-
```bash
|
|
226
|
-
nx-ce status # List all instances
|
|
227
|
-
nx-ce status --name chat-1 # Show specific instance
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
```json
|
|
231
|
-
{ "name": "chat-1", "pid": 12345, "lifecycleState": "running",
|
|
232
|
-
"sessionId": "sess_abc", "model": "claude-sonnet-4-6",
|
|
233
|
-
"port": 3100, "host": "MY-PC" }
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
---
|
|
237
|
-
|
|
238
281
|
## `nx-ce skills` — List Available Skills / 列出可用 Skill
|
|
239
282
|
|
|
240
283
|
```bash
|
|
@@ -252,18 +295,16 @@ nx-ce skills --cwd "D:/project"
|
|
|
252
295
|
|
|
253
296
|
## State Persistence / 状态持久化
|
|
254
297
|
|
|
255
|
-
State files at `~/.nx-ce/instances/{
|
|
298
|
+
State files at `~/.nx-ce/instances/{key}.json`. Key format: `{name}~{cwd}`.
|
|
256
299
|
|
|
257
300
|
```json
|
|
258
301
|
{
|
|
259
|
-
"name": "
|
|
260
|
-
"pid": 12345,
|
|
261
|
-
"startedAt": "2026-06-06T10:30:00.000Z",
|
|
262
|
-
"updatedAt": "2026-06-06T11:00:00.000Z",
|
|
302
|
+
"name": "proj-a:D~/project-a",
|
|
263
303
|
"sessionId": "sess_abc123",
|
|
264
304
|
"model": "claude-sonnet-4-6",
|
|
305
|
+
"cwd": "D:/project-a",
|
|
265
306
|
"host": "MY-PC",
|
|
266
|
-
"machineId": "
|
|
307
|
+
"machineId": "744e51b9-...",
|
|
267
308
|
"lifecycleState": "running",
|
|
268
309
|
"port": 3100,
|
|
269
310
|
"usage": { "inputTokens": 1500, "outputTokens": 3200, ... }
|
|
@@ -279,21 +320,6 @@ State files at `~/.nx-ce/instances/{name}.json`:
|
|
|
279
320
|
|
|
280
321
|
---
|
|
281
322
|
|
|
282
|
-
## Architecture / 架构
|
|
283
|
-
|
|
284
|
-
```
|
|
285
|
-
Chrome Extension / 浏览器扩展
|
|
286
|
-
↕ WebSocket (ws://127.0.0.1:3100)
|
|
287
|
-
nx-ce serve (Node.js)
|
|
288
|
-
├─ SessionManager → agentQuery()
|
|
289
|
-
│ ↕
|
|
290
|
-
│ Claude CLI
|
|
291
|
-
│
|
|
292
|
-
└─ (Native Host via exec.Command → nx-ce query --resume)
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
---
|
|
296
|
-
|
|
297
323
|
## Development / 开发
|
|
298
324
|
|
|
299
325
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nx-ce",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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
|
-
*
|
|
75
|
+
* 以 (name, cwd) 为唯一标识。
|
|
76
|
+
* 如果另一个协程正在创建同 key session,则等待其完成。
|
|
51
77
|
*
|
|
52
|
-
* @param {string} name -
|
|
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(
|
|
86
|
+
const existing = this.sessions.get(key);
|
|
58
87
|
if (existing && !existing.closed) {
|
|
59
|
-
|
|
60
|
-
this._cancelIdleTimer(name);
|
|
88
|
+
this._cancelIdleTimer(key);
|
|
61
89
|
return existing;
|
|
62
90
|
}
|
|
63
91
|
|
|
64
92
|
// 正在被另一个协程创建 → 等它
|
|
65
|
-
if (this._pendingCreates.has(
|
|
66
|
-
return this._pendingCreates.get(
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
117
|
+
// 检查是否有可恢复的会话状态(按 key 存储,实现不同目录独立状态)
|
|
118
|
+
const existingState = readState(key);
|
|
89
119
|
|
|
90
120
|
// 组装 SDK 选项
|
|
91
121
|
const sdkOptions = {
|
|
92
|
-
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,
|
|
165
|
-
queue: [],
|
|
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,
|
|
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(
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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(
|
|
349
|
+
this._idleTimers.delete(key);
|
|
331
350
|
}
|
|
332
351
|
}
|
|
333
352
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
this.
|
|
337
|
-
|
|
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(
|
|
354
|
-
const session = this.sessions.get(
|
|
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(
|
|
373
|
+
this._cancelIdleTimer(key);
|
|
358
374
|
|
|
359
|
-
// 关闭 MessageChannel → SDK next() 返回 done
|
|
360
375
|
session.closeChannel();
|
|
361
376
|
|
|
362
|
-
|
|
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(
|
|
380
|
+
this.sessions.delete(key);
|
|
373
381
|
|
|
374
|
-
// 如果是正常关闭才清理状态文件(crash 留文件便于恢复)
|
|
375
382
|
if (reason !== 'crash') {
|
|
376
|
-
deleteState(
|
|
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
|
|
385
|
-
await Promise.allSettled(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
519
|
-
|
|
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(([
|
|
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 [
|
|
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(
|
|
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
|
|
package/src/session-store.js
CHANGED
|
@@ -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
|
-
|
|
142
|
+
const safe = String(name).replace(/[^a-zA-Z0-9._~-]/g, '_').replace(/:/g, '~');
|
|
143
|
+
return `${safe}.json`;
|
|
142
144
|
}
|