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 CHANGED
@@ -3,233 +3,332 @@
3
3
  [![npm version](https://img.shields.io/npm/v/nx-ce)](https://www.npmjs.com/package/nx-ce)
4
4
  [![CI](https://github.com/joke-lx/nx-ce/actions/workflows/npm-publish.yml/badge.svg)](https://github.com/joke-lx/nx-ce/actions/workflows/npm-publish.yml)
5
5
 
6
- **nx-ce** 是一个轻量级 Node.js 适配器,封装了 `@anthropic-ai/claude-agent-sdk`。
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
- **nx-ce** is a lightweight Node.js adapter for `@anthropic-ai/claude-agent-sdk`.
11
- It exposes the SDK via a WebSocket server or stdin/stdout protocol,
12
- supporting both one-shot cold-start queries and persistent server sessions.
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
- ## 项目家族 / Family
16
+ ## Family / 项目家族
17
17
 
18
- | Package | 角色 / Role |
18
+ | Package | Role / 角色 |
19
19
  |---------|-------------|
20
- | **nx-ce** | Claude Engine — SDK 适配层 / SDK adapter layer |
21
- | [nx-sx](https://github.com/jokelx/nx-sx) | Sandbox eXecution — 窗口/终端管理器 / window & terminal manager |
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
- ## 安装 / Install
25
+ ## Install / 安装
26
26
 
27
27
  ```bash
28
28
  npm install nx-ce
29
- # 或全局安装 / or install globally
29
+ # or globally
30
30
  npm install -g nx-ce
31
31
  ```
32
32
 
33
33
  ---
34
34
 
35
- ## 命令行用法 / CLI Usage
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
- ### `nx-ce query <prompt>` 一次性冷启动查询 / One-shot cold-start query
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
- | 选项 / Flag | 说明 / Description |
48
- |-------------|-------------------|
49
- | `--model <id>` | 模型 ID 覆盖(默认 `claude-sonnet-4-6`)/ Model override |
50
- | `--claude-path <path>` | Claude CLI 可执行文件路径 / Path to Claude CLI binary |
51
- | `--system-prompt <text>` | 系统提示词覆盖 / System prompt override |
52
- | `--resume <sessionId>` | 续接之前的会话(长对话)/ Resume a prior session |
53
- | `--skill <name>[,<name>...]` | 加载指定 Skill(逗号分隔,传 `all` 加载全部)/ Load specific skills |
54
- | `--include-metadata` | 输出中附带 skills/tools/slash_commands 信息 / Include skill/tool metadata in output |
55
- | `--no-persist` | 不持久化会话 / Don't persist session |
56
- | `--env "KEY=value,KEY2=val"` | 额外环境变量 / Extra environment variables |
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
- ### `nx-ce serve` — WebSocket 持久化服务器 / WebSocket server
69
+ ### JSON output
59
70
 
60
- 单例进程,多客户端共享一个 SDK 会话,请求排队处理。
61
- Single process with multi-client support and FIFO query queue.
71
+ ```json
72
+ // Default
73
+ { "text": "2", "sessionId": "sess_abc" }
62
74
 
63
- ```bash
64
- nx-ce serve # 默认端口 3100
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
- | 选项 / Flag | 说明 / Description |
70
- |-------------|-------------------|
71
- | `--name <name>` | 实例名称(默认 `"default"`)/ Instance name |
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
- > WebSocket 地址: `ws://127.0.0.1:3100`
83
+ **Single process. Multiple concurrent sessions. FIFO queries per session.**
78
84
 
79
- ### `nx-ce status` — 查看实例状态 / Show instance state
85
+ 单例进程。多会话隔离。每个会话独立 SDK agentQuery,互不阻塞。
80
86
 
81
87
  ```bash
82
- nx-ce status # 列出所有实例 / List all instances
83
- nx-ce status --name chat-tab-1 # 查看指定实例 / Show specific instance
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
- ### `nx-ce help` 显示帮助 / Show help
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 help
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 协议 / WebSocket Protocol
95
-
96
- 服务端地址 `ws://127.0.0.1:PORT`(默认 3100)。所有消息均为 JSON 字符串(不含长度前缀)。
113
+ ## WebSocket Protocol / WebSocket 协议
97
114
 
98
- Server at `ws://127.0.0.1:PORT` (default 3100). All messages are JSON strings (no length prefix).
115
+ Server: `ws://127.0.0.1:PORT`. All messages are JSON (no length prefix).
99
116
 
100
- ### 客户端发送 / Client → Server
117
+ ### Client → Server / 客户端发送
101
118
 
102
- | type | 字段 / Fields | 说明 / Description |
119
+ | type | Fields / 字段 | Description / 说明 |
103
120
  |------|---------------|-------------------|
104
- | `query` | `prompt: string`, `id?: string` | 发起查询 / Submit a query |
105
- | `ping` | | 心跳检测 / Heartbeat |
106
- | `getSkills` | | 拉取技能/工具列表 / Fetch skills & tools |
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
- ### 服务端发送 / Server → Client
139
+ ### Server → Client / 服务端发送
115
140
 
116
- **连接建立 / On connect:**
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": [...], "tools": [...], "slashCommands": [...], "agents": [...] }
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
- **查询响应 / Query response (streamed chunks):**
158
+ **Query response (streamed chunks) / 查询响应(流式块):**
125
159
 
126
160
  ```json
127
- ← { "type": "text", "content": "这是一段回复..." }
128
- ← { "type": "thinking", "content": "模型思考过程..." }
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
- **其他 / Other:**
168
+ **Other / 其他:**
134
169
 
135
170
  ```json
136
- ← { "type": "pong", "sessionId": "sess_xxx" }
137
- ← { "type": "skills", "skills": [...], "tools": [...], "slashCommands": [...], "agents": [...] }
138
- ← { "type": "error", "content": "error message" }
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
- ### 完整示例 / Full exchange
179
+ ### Full exchange example / 完整示例
142
180
 
143
181
  ```
144
- → { "type": "query", "prompt": "Hello" }
145
- ← { "type": "text", "content": "Hello! How can I help you today?" }
146
- ← { "type": "done", "sessionId": "sess_abc123" }
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": "ping" }
149
- ← { "type": "pong", "sessionId": "sess_abc123" }
187
+ → { "type":"ping" }
188
+ ← { "type":"pong", "sessionId":"sess_abc", "serverTime":... }
150
189
  ```
151
190
 
152
- ### 单例机制 / Singleton guarantee
191
+ ---
153
192
 
154
- 重复启动 `nx-ce serve` 会在同一端口上失败:
193
+ ## Multi-Session Architecture / 多会话架构
155
194
 
156
195
  ```
157
- 端口 3100 已被占用 nx-ce 单例进程已在运行中
158
- Port 3100 already in use — another nx-ce instance is running
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
- `nx-ce query` 子命令仍使用长度前缀 JSON(Chrome Native Messaging 格式):
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
- ### 查询(一次性)/ Query (one-shot)
223
+ ## `nx-ce status` — Instance Status / 查看实例状态
172
224
 
173
- ```
174
- { "prompt": "...", "model": "...", "systemPrompt": "..." }
175
- { "text": "...", "sessionId": "sess_xxx" }
225
+ ```bash
226
+ nx-ce status # List all instances
227
+ nx-ce status --name chat-1 # Show specific instance
176
228
  ```
177
229
 
178
- ### 带元数据输出 / With metadata
179
-
180
- ```
181
- ← { "text": "...", "sessionId": "sess_xxx",
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
- ## 架构 / Architecture
238
+ ## `nx-ce skills` — List Available Skills / 列出可用 Skill
188
239
 
240
+ ```bash
241
+ nx-ce skills --cwd "D:/project"
189
242
  ```
190
- Chrome Extension / 浏览器扩展
191
- ↕ WebSocket (ws://127.0.0.1:3100)
192
- nx-ce serve (Node.js) ← 单例进程 / singleton process
193
- @anthropic-ai/claude-agent-sdk
194
- Claude Code CLI (子进程 / subprocess)
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
- ## 状态持久化 / State
200
-
201
- 状态持久化到 `~/.nx-ce/instances/{name}.json`。
202
- 每个命名实例存储其 PID、会话 ID 和启动时间,用于崩溃恢复和会话续接。
253
+ ## State Persistence / 状态持久化
203
254
 
204
- Persisted to `~/.nx-ce/instances/{name}.json`. Each named instance stores its PID, session ID, and start time for crash recovery and session resumption.
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
- ## 开发 / Development
297
+ ## Development / 开发
219
298
 
220
299
  ```bash
221
- # 本地运行一次查询 / Run a one-shot query
300
+ # One-shot query
222
301
  node ./bin/nx-ce.js query "你好"
223
302
 
224
- # 启动 WebSocket 服务 / Start WebSocket server
303
+ # Start server
225
304
  node ./bin/nx-ce.js serve --port 3100
226
305
 
227
- # 检查语法 / Check syntax
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
- ## License / 许可证
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.4",
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
  }