nx-ce 0.1.4 → 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/README.md +210 -111
- package/package.json +1 -1
- package/src/serve.js +116 -100
- package/src/session-store.js +4 -2
package/README.md
CHANGED
|
@@ -3,233 +3,332 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/nx-ce)
|
|
4
4
|
[](https://github.com/joke-lx/nx-ce/actions/workflows/npm-publish.yml)
|
|
5
5
|
|
|
6
|
-
**nx-ce**
|
|
7
|
-
通过长度前缀的 JSON 协议在 stdin/stdout 上暴露 SDK 接口,
|
|
8
|
-
支持一次性冷启动查询与 WebSocket 持久化服务器两种运行模式。
|
|
6
|
+
**nx-ce** is a lightweight Node.js adapter for `@anthropic-ai/claude-agent-sdk`. It provides two modes:
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
- **`nx-ce query`** — one-shot cold-start queries (stateless, CLI-friendly)
|
|
9
|
+
- **`nx-ce serve`** — WebSocket multi-session server (persistent, concurrent clients)
|
|
10
|
+
|
|
11
|
+
**nx-ce** 是一个轻量级 Node.js 适配器,封装了 `@anthropic-ai/claude-agent-sdk`。支持两种运行模式:
|
|
12
|
+
一次性冷启动查询与多会话 WebSocket 持久化服务器。
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Family / 项目家族
|
|
17
17
|
|
|
18
|
-
| Package |
|
|
18
|
+
| Package | Role / 角色 |
|
|
19
19
|
|---------|-------------|
|
|
20
|
-
| **nx-ce** | Claude Engine — SDK
|
|
21
|
-
| [nx-sx](https://github.com/jokelx/nx-sx) | Sandbox eXecution —
|
|
20
|
+
| **nx-ce** | Claude Engine — SDK adapter layer / SDK 适配层 |
|
|
21
|
+
| [nx-sx](https://github.com/jokelx/nx-sx) | Sandbox eXecution — window & terminal manager / 窗口终端管理器 |
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Install / 安装
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
npm install nx-ce
|
|
29
|
-
#
|
|
29
|
+
# or globally
|
|
30
30
|
npm install -g nx-ce
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
##
|
|
35
|
+
## Quick Start / 快速开始
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# One-shot query (stateless)
|
|
39
|
+
nx-ce query "用中文回答:1+1=?" --model claude-haiku-4-5
|
|
40
|
+
|
|
41
|
+
# Start WebSocket server (persistent, multi-session)
|
|
42
|
+
nx-ce serve --port 3100
|
|
43
|
+
|
|
44
|
+
# In another terminal, connect via WebSocket (see test/serve-test.mjs)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
## `nx-ce query` — One-shot Cold-Start Query / 一次性冷启动查询
|
|
38
50
|
|
|
39
51
|
```bash
|
|
40
52
|
nx-ce query "解释这段代码" --model claude-sonnet-4-6
|
|
41
|
-
nx-ce query "Explain this code" --model claude-haiku-4-5 --no-persist
|
|
42
53
|
nx-ce query "继续之前的对话" --resume sess_abc123
|
|
43
54
|
nx-ce query "Analyze" --skill git-workflow,code-review
|
|
44
55
|
nx-ce query "Analyze" --skill all
|
|
45
56
|
```
|
|
46
57
|
|
|
47
|
-
|
|
|
48
|
-
|
|
49
|
-
| `--model <id>` |
|
|
50
|
-
| `--claude-path <path>` | Claude CLI
|
|
51
|
-
| `--system-prompt <text>` |
|
|
52
|
-
| `--resume <sessionId>` |
|
|
53
|
-
| `--skill <name>[,<name>...]` |
|
|
54
|
-
| `--include-metadata` |
|
|
55
|
-
| `--no-persist` |
|
|
56
|
-
| `--env "KEY=
|
|
58
|
+
| Flag | Description / 说明 |
|
|
59
|
+
|------|-------------------|
|
|
60
|
+
| `--model <id>` | Model override (default `claude-sonnet-4-6`) / 模型 ID |
|
|
61
|
+
| `--claude-path <path>` | Path to Claude CLI binary / Claude CLI 路径 |
|
|
62
|
+
| `--system-prompt <text>` | System prompt override / 系统提示词覆盖 |
|
|
63
|
+
| `--resume <sessionId>` | Resume a prior session (long conversation) / 续接会话 |
|
|
64
|
+
| `--skill <name>[,<name>...]` | Load specific skills (comma-separated, or `all`) / 加载 Skill |
|
|
65
|
+
| `--include-metadata` | Include skills/tools/slashCommands in output / 附带元数据 |
|
|
66
|
+
| `--no-persist` | Don't persist session / 不持久化 |
|
|
67
|
+
| `--env "KEY=val,KEY2=val"` | Extra environment variables / 额外环境变量 |
|
|
57
68
|
|
|
58
|
-
###
|
|
69
|
+
### JSON output
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
```json
|
|
72
|
+
// Default
|
|
73
|
+
{ "text": "2", "sessionId": "sess_abc" }
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
nx-ce serve --port 3100 # 指定端口
|
|
66
|
-
nx-ce serve --name chat-tab-1
|
|
75
|
+
// With --include-metadata
|
|
76
|
+
{ "text": "2", "sessionId": "sess_abc", "metadata": { "skills": [...], "tools": [...], ... } }
|
|
67
77
|
```
|
|
68
78
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
| `--port <port>` | WebSocket 端口(默认 `3100`)/ WebSocket port |
|
|
73
|
-
| `--model <id>` | 模型 ID 覆盖 / Model override |
|
|
74
|
-
| `--claude-path <path>` | Claude CLI 可执行文件路径 / Path to Claude CLI binary |
|
|
75
|
-
| `--env "KEY=value,..."` | 额外环境变量 / Extra environment variables |
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## `nx-ce serve` — WebSocket Multi-Session Server / WebSocket 多会话服务器
|
|
76
82
|
|
|
77
|
-
|
|
83
|
+
**Single process. Multiple concurrent sessions. FIFO queries per session.**
|
|
78
84
|
|
|
79
|
-
|
|
85
|
+
单例进程。多会话隔离。每个会话独立 SDK agentQuery,互不阻塞。
|
|
80
86
|
|
|
81
87
|
```bash
|
|
82
|
-
nx-ce
|
|
83
|
-
nx-ce
|
|
88
|
+
nx-ce serve # default port 3100
|
|
89
|
+
nx-ce serve --port 3100
|
|
90
|
+
nx-ce serve --name "main" --port 3100 --cwd "D:/project"
|
|
84
91
|
```
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
| Flag | Description / 说明 |
|
|
94
|
+
|------|-------------------|
|
|
95
|
+
| `--name <name>` | Instance name (default `default`) / 实例名称 |
|
|
96
|
+
| `--port <port>` | WebSocket port (default `3100`) / 端口 |
|
|
97
|
+
| `--model <id>` | Model override / 模型 ID |
|
|
98
|
+
| `--claude-path <path>` | Path to Claude CLI / CLI 路径 |
|
|
99
|
+
| `--cwd <path>` | Working directory / 工作目录 |
|
|
100
|
+
| `--env "KEY=val,..."` | Extra env vars / 额外环境变量 |
|
|
101
|
+
|
|
102
|
+
> WebSocket address: `ws://127.0.0.1:3100` (localhost only)
|
|
103
|
+
|
|
104
|
+
### Singleton guarantee / 单例保证
|
|
87
105
|
|
|
88
106
|
```bash
|
|
89
|
-
nx-ce
|
|
107
|
+
nx-ce serve --port 3100 # first → OK
|
|
108
|
+
nx-ce serve --port 3100 # second → Port 3100 already in use — another nx-ce serve is running
|
|
90
109
|
```
|
|
91
110
|
|
|
92
111
|
---
|
|
93
112
|
|
|
94
|
-
## WebSocket
|
|
95
|
-
|
|
96
|
-
服务端地址 `ws://127.0.0.1:PORT`(默认 3100)。所有消息均为 JSON 字符串(不含长度前缀)。
|
|
113
|
+
## WebSocket Protocol / WebSocket 协议
|
|
97
114
|
|
|
98
|
-
Server
|
|
115
|
+
Server: `ws://127.0.0.1:PORT`. All messages are JSON (no length prefix).
|
|
99
116
|
|
|
100
|
-
###
|
|
117
|
+
### Client → Server / 客户端发送
|
|
101
118
|
|
|
102
|
-
| type |
|
|
119
|
+
| type | Fields / 字段 | Description / 说明 |
|
|
103
120
|
|------|---------------|-------------------|
|
|
104
|
-
| `query` | `prompt: string`, `id?: string` |
|
|
105
|
-
| `ping` |
|
|
106
|
-
| `getSkills` |
|
|
121
|
+
| `query` | `prompt: string`, `session?: string`, `id?: string` | Submit a query / 发起查询 |
|
|
122
|
+
| `ping` | — | Heartbeat / 心跳 |
|
|
123
|
+
| `getSkills` | `session?: string` | Fetch skills/tools/agents / 拉取元数据 |
|
|
124
|
+
| `getStatus` | `session?: string` | Query session status / 查询状态 |
|
|
125
|
+
| `closeSession` | `session: string` | Close a session / 关闭会话 |
|
|
126
|
+
| `listSessions` | — | List all active sessions / 列出会话 |
|
|
127
|
+
|
|
128
|
+
`session` defaults to `"default"` if omitted.
|
|
107
129
|
|
|
108
130
|
```json
|
|
109
|
-
→ { "type": "query", "prompt": "
|
|
131
|
+
→ { "type": "query", "session": "tab-1", "prompt": "分析这个目录" }
|
|
110
132
|
→ { "type": "ping" }
|
|
111
|
-
→ { "type": "getSkills" }
|
|
133
|
+
→ { "type": "getSkills", "session": "tab-1" }
|
|
134
|
+
→ { "type": "getStatus", "session": "tab-1" }
|
|
135
|
+
→ { "type": "closeSession", "session": "tab-1" }
|
|
136
|
+
→ { "type": "listSessions" }
|
|
112
137
|
```
|
|
113
138
|
|
|
114
|
-
###
|
|
139
|
+
### Server → Client / 服务端发送
|
|
115
140
|
|
|
116
|
-
|
|
141
|
+
**Connection / 连接建立:**
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
← { "type": "connected", "port": 3100, "host": "MY-PC",
|
|
145
|
+
"machineId": "744e51b9-ad7d-85bb-1600-bbfb", "serverTime": 1780736149028 }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Session init (auto-push on first query per session) / 会话初始化(自动推送):**
|
|
117
149
|
|
|
118
150
|
```json
|
|
119
|
-
← { "type": "connected", "sessionId": "sess_xxx", "port": 3100 }
|
|
120
151
|
← { "type": "init", "sessionId": "sess_xxx", "model": "claude-sonnet-4-6",
|
|
121
|
-
"skills": [
|
|
152
|
+
"skills": ["browse", "code-review", ...],
|
|
153
|
+
"tools": ["Read", "Edit", "Bash", ...],
|
|
154
|
+
"slashCommands": ["code-review", "ship", ...],
|
|
155
|
+
"agents": ["Explore", "code-reviewer", ...] }
|
|
122
156
|
```
|
|
123
157
|
|
|
124
|
-
|
|
158
|
+
**Query response (streamed chunks) / 查询响应(流式块):**
|
|
125
159
|
|
|
126
160
|
```json
|
|
127
|
-
← { "type": "
|
|
128
|
-
← { "type": "
|
|
161
|
+
← { "type": "turn_start", "turn": "turn_xxx", "time": ... }
|
|
162
|
+
← { "type": "text", "content": "这是一段回复...", "time": ... }
|
|
163
|
+
← { "type": "thinking", "content": "模型思考过程...", "time": ... }
|
|
129
164
|
← { "type": "tool_use", "name": "readFile", "input": {...}, "id": "toolu_xxx" }
|
|
130
|
-
← { "type": "done", "sessionId": "sess_xxx" }
|
|
165
|
+
← { "type": "done", "sessionId": "sess_xxx", "time": ... }
|
|
131
166
|
```
|
|
132
167
|
|
|
133
|
-
|
|
168
|
+
**Other / 其他:**
|
|
134
169
|
|
|
135
170
|
```json
|
|
136
|
-
← { "type": "pong",
|
|
137
|
-
← { "type": "skills",
|
|
138
|
-
← { "type": "
|
|
171
|
+
← { "type": "pong", "sessionId": "sess_xxx", "serverTime": ... }
|
|
172
|
+
← { "type": "skills", "skills": [...], "tools": [...], ... }
|
|
173
|
+
← { "type": "status", "session": "tab-1", "sessionId": "sess_xxx", "isActive": true, "queueLength": 0, "processing": false }
|
|
174
|
+
← { "type": "session_list", "sessions": [{ "name": "tab-1", ... }, ...] }
|
|
175
|
+
← { "type": "session_closed","session": "tab-1" }
|
|
176
|
+
← { "type": "error", "content": "error message" }
|
|
139
177
|
```
|
|
140
178
|
|
|
141
|
-
###
|
|
179
|
+
### Full exchange example / 完整示例
|
|
142
180
|
|
|
143
181
|
```
|
|
144
|
-
→ { "type":
|
|
145
|
-
← { "type":
|
|
146
|
-
← { "type":
|
|
182
|
+
→ { "type":"query", "session":"tab-1", "prompt":"Hello" }
|
|
183
|
+
← { "type":"turn_start", "turn":"turn_xxx", "time":... }
|
|
184
|
+
← { "type":"text", "content":"Hello! How can I help you today?" }
|
|
185
|
+
← { "type":"done", "sessionId":"sess_abc", "time":... }
|
|
147
186
|
|
|
148
|
-
→ { "type":
|
|
149
|
-
← { "type":
|
|
187
|
+
→ { "type":"ping" }
|
|
188
|
+
← { "type":"pong", "sessionId":"sess_abc", "serverTime":... }
|
|
150
189
|
```
|
|
151
190
|
|
|
152
|
-
|
|
191
|
+
---
|
|
153
192
|
|
|
154
|
-
|
|
193
|
+
## Multi-Session Architecture / 多会话架构
|
|
155
194
|
|
|
156
195
|
```
|
|
157
|
-
|
|
158
|
-
|
|
196
|
+
nx-ce serve (single Node.js process)
|
|
197
|
+
┌───────────────────────────────────────────────────────────┐
|
|
198
|
+
│ WebSocket Server (127.0.0.1:3100) │
|
|
199
|
+
│ │
|
|
200
|
+
│ SessionManager │
|
|
201
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
202
|
+
│ │ "tab-1": { agentQuery(), messageChannel, queue } │ │
|
|
203
|
+
│ │ "tab-2": { agentQuery(), messageChannel, queue } │ │
|
|
204
|
+
│ │ "tab-3": { agentQuery(), messageChannel, queue } │ │
|
|
205
|
+
│ └──────────────────────┬──────────────────────────────┘ │
|
|
206
|
+
│ spawn each | (SDK manages CLI processes) │
|
|
207
|
+
│ Claude CLI ─────┴──── Claude CLI ───── Claude CLI │
|
|
208
|
+
└───────────────────────────────────────────────────────────┘
|
|
159
209
|
```
|
|
160
210
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
## 协议 / Protocol (stdin/stdout)
|
|
211
|
+
### Concurrency guarantees / 竞态保护
|
|
164
212
|
|
|
165
|
-
|
|
213
|
+
| Race / 竞态 | Solution / 方案 |
|
|
214
|
+
|-------------|----------------|
|
|
215
|
+
| Concurrent session creation | `_pendingCreates` Map deduplicates in-flight creation promises |
|
|
216
|
+
| SDK response routing | Each session has independent `for await` loop, writes only to `session.client` |
|
|
217
|
+
| State file overwrite | Per-session files (`{name}.json`) + write lock |
|
|
218
|
+
| Message ordering | Per-session `MonotonicClock` ensures strict time ordering |
|
|
219
|
+
| Client disconnect cleanup | Null client ref + clear queue + 5-min idle timeout auto-destroy |
|
|
166
220
|
|
|
167
|
-
|
|
168
|
-
[4 bytes LE uint32 = 负载长度 / payload length][UTF-8 JSON payload]
|
|
169
|
-
```
|
|
221
|
+
---
|
|
170
222
|
|
|
171
|
-
|
|
223
|
+
## `nx-ce status` — Instance Status / 查看实例状态
|
|
172
224
|
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
|
|
225
|
+
```bash
|
|
226
|
+
nx-ce status # List all instances
|
|
227
|
+
nx-ce status --name chat-1 # Show specific instance
|
|
176
228
|
```
|
|
177
229
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"metadata": { "skills": [...], "tools": [...], "slashCommands": [...] } }
|
|
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" }
|
|
183
234
|
```
|
|
184
235
|
|
|
185
236
|
---
|
|
186
237
|
|
|
187
|
-
##
|
|
238
|
+
## `nx-ce skills` — List Available Skills / 列出可用 Skill
|
|
188
239
|
|
|
240
|
+
```bash
|
|
241
|
+
nx-ce skills --cwd "D:/project"
|
|
189
242
|
```
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
{ "skills": ["code-review", "browse", ...],
|
|
246
|
+
"tools": ["Read", "Edit", "Bash", ...],
|
|
247
|
+
"slashCommands": ["code-review", ...],
|
|
248
|
+
"agents": ["Explore", ...] }
|
|
195
249
|
```
|
|
196
250
|
|
|
197
251
|
---
|
|
198
252
|
|
|
199
|
-
##
|
|
200
|
-
|
|
201
|
-
状态持久化到 `~/.nx-ce/instances/{name}.json`。
|
|
202
|
-
每个命名实例存储其 PID、会话 ID 和启动时间,用于崩溃恢复和会话续接。
|
|
253
|
+
## State Persistence / 状态持久化
|
|
203
254
|
|
|
204
|
-
|
|
255
|
+
State files at `~/.nx-ce/instances/{name}.json`:
|
|
205
256
|
|
|
206
257
|
```json
|
|
207
258
|
{
|
|
208
259
|
"name": "chat-tab-1",
|
|
209
260
|
"pid": 12345,
|
|
210
261
|
"startedAt": "2026-06-06T10:30:00.000Z",
|
|
262
|
+
"updatedAt": "2026-06-06T11:00:00.000Z",
|
|
211
263
|
"sessionId": "sess_abc123",
|
|
212
|
-
"model": "claude-sonnet-4-6"
|
|
264
|
+
"model": "claude-sonnet-4-6",
|
|
265
|
+
"host": "MY-PC",
|
|
266
|
+
"machineId": "a1b2c3d4-e5f6-...",
|
|
267
|
+
"lifecycleState": "running",
|
|
268
|
+
"port": 3100,
|
|
269
|
+
"usage": { "inputTokens": 1500, "outputTokens": 3200, ... }
|
|
213
270
|
}
|
|
214
271
|
```
|
|
215
272
|
|
|
273
|
+
| lifecycleState | Meaning / 含义 |
|
|
274
|
+
|----------------|----------------|
|
|
275
|
+
| `running` | Normal operation / 正常运行 |
|
|
276
|
+
| `stopped` | Clean shutdown / 正常关闭 |
|
|
277
|
+
| `crashed` | Unexpected exit / 异常退出 |
|
|
278
|
+
| `resuming` | Session recovery in progress / 恢复中 |
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
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
|
+
|
|
216
295
|
---
|
|
217
296
|
|
|
218
|
-
##
|
|
297
|
+
## Development / 开发
|
|
219
298
|
|
|
220
299
|
```bash
|
|
221
|
-
#
|
|
300
|
+
# One-shot query
|
|
222
301
|
node ./bin/nx-ce.js query "你好"
|
|
223
302
|
|
|
224
|
-
#
|
|
303
|
+
# Start server
|
|
225
304
|
node ./bin/nx-ce.js serve --port 3100
|
|
226
305
|
|
|
227
|
-
#
|
|
306
|
+
# Run tests (in another terminal)
|
|
307
|
+
node test/serve-test.mjs
|
|
308
|
+
|
|
309
|
+
# Syntax check
|
|
228
310
|
node -c src/*.js
|
|
229
311
|
```
|
|
230
312
|
|
|
231
313
|
---
|
|
232
314
|
|
|
233
|
-
##
|
|
315
|
+
## Test / 测试
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
# Terminal 1: start server
|
|
319
|
+
node bin/nx-ce.js serve --port 3100
|
|
320
|
+
|
|
321
|
+
# Terminal 2: run tests
|
|
322
|
+
node test/serve-test.mjs
|
|
323
|
+
|
|
324
|
+
# Expected output:
|
|
325
|
+
# PASS: 14 FAIL: 0
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Tests cover: connection, ping/pong, single-session query, multi-session isolation, 3 concurrent sessions, long conversation resume, listSessions, closeSession, getSkills, getStatus.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## License
|
|
234
333
|
|
|
235
334
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nx-ce",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
*
|
|
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
|
}
|