nx-ce 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -51
- package/package.json +3 -2
- package/src/cli.js +4 -2
- package/src/index.js +9 -1
- package/src/serve.js +553 -198
- package/src/session-store.js +56 -2
- package/src/util.js +124 -0
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
[](https://github.com/joke-lx/nx-ce/actions/workflows/npm-publish.yml)
|
|
5
5
|
|
|
6
6
|
**nx-ce** 是一个轻量级 Node.js 适配器,封装了 `@anthropic-ai/claude-agent-sdk`。
|
|
7
|
-
通过长度前缀的 JSON
|
|
8
|
-
|
|
7
|
+
通过长度前缀的 JSON 协议在 stdin/stdout 上暴露 SDK 接口,
|
|
8
|
+
支持一次性冷启动查询与 WebSocket 持久化服务器两种运行模式。
|
|
9
9
|
|
|
10
10
|
**nx-ce** is a lightweight Node.js adapter for `@anthropic-ai/claude-agent-sdk`.
|
|
11
|
-
It exposes the SDK
|
|
12
|
-
supporting both one-shot cold-start queries and persistent
|
|
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.
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -55,23 +55,27 @@ nx-ce query "Analyze" --skill all
|
|
|
55
55
|
| `--no-persist` | 不持久化会话 / Don't persist session |
|
|
56
56
|
| `--env "KEY=value,KEY2=val"` | 额外环境变量 / Extra environment variables |
|
|
57
57
|
|
|
58
|
-
### `nx-ce serve` —
|
|
58
|
+
### `nx-ce serve` — WebSocket 持久化服务器 / WebSocket server
|
|
59
|
+
|
|
60
|
+
单例进程,多客户端共享一个 SDK 会话,请求排队处理。
|
|
61
|
+
Single process with multi-client support and FIFO query queue.
|
|
59
62
|
|
|
60
63
|
```bash
|
|
64
|
+
nx-ce serve # 默认端口 3100
|
|
65
|
+
nx-ce serve --port 3100 # 指定端口
|
|
61
66
|
nx-ce serve --name chat-tab-1
|
|
62
|
-
nx-ce serve --name default --model claude-sonnet-4-6
|
|
63
67
|
```
|
|
64
68
|
|
|
65
|
-
通过 stdin/stdout 接收 4B+JSON 协议消息,保持一个持久化的 SDK 会话。
|
|
66
|
-
Reads/writes 4B+JSON protocol messages over stdin/stdout, maintaining a persistent SDK session.
|
|
67
|
-
|
|
68
69
|
| 选项 / Flag | 说明 / Description |
|
|
69
70
|
|-------------|-------------------|
|
|
70
71
|
| `--name <name>` | 实例名称(默认 `"default"`)/ Instance name |
|
|
72
|
+
| `--port <port>` | WebSocket 端口(默认 `3100`)/ WebSocket port |
|
|
71
73
|
| `--model <id>` | 模型 ID 覆盖 / Model override |
|
|
72
74
|
| `--claude-path <path>` | Claude CLI 可执行文件路径 / Path to Claude CLI binary |
|
|
73
75
|
| `--env "KEY=value,..."` | 额外环境变量 / Extra environment variables |
|
|
74
76
|
|
|
77
|
+
> WebSocket 地址: `ws://127.0.0.1:3100`
|
|
78
|
+
|
|
75
79
|
### `nx-ce status` — 查看实例状态 / Show instance state
|
|
76
80
|
|
|
77
81
|
```bash
|
|
@@ -87,60 +91,96 @@ nx-ce help
|
|
|
87
91
|
|
|
88
92
|
---
|
|
89
93
|
|
|
90
|
-
## 协议 / Protocol
|
|
94
|
+
## WebSocket 协议 / WebSocket Protocol
|
|
95
|
+
|
|
96
|
+
服务端地址 `ws://127.0.0.1:PORT`(默认 3100)。所有消息均为 JSON 字符串(不含长度前缀)。
|
|
97
|
+
|
|
98
|
+
Server at `ws://127.0.0.1:PORT` (default 3100). All messages are JSON strings (no length prefix).
|
|
91
99
|
|
|
92
|
-
|
|
100
|
+
### 客户端发送 / Client → Server
|
|
93
101
|
|
|
94
|
-
|
|
102
|
+
| type | 字段 / Fields | 说明 / Description |
|
|
103
|
+
|------|---------------|-------------------|
|
|
104
|
+
| `query` | `prompt: string`, `id?: string` | 发起查询 / Submit a query |
|
|
105
|
+
| `ping` | 无 | 心跳检测 / Heartbeat |
|
|
106
|
+
| `getSkills` | 无 | 拉取技能/工具列表 / Fetch skills & tools |
|
|
95
107
|
|
|
108
|
+
```json
|
|
109
|
+
→ { "type": "query", "prompt": "解释这段代码" }
|
|
110
|
+
→ { "type": "ping" }
|
|
111
|
+
→ { "type": "getSkills" }
|
|
96
112
|
```
|
|
97
|
-
|
|
113
|
+
|
|
114
|
+
### 服务端发送 / Server → Client
|
|
115
|
+
|
|
116
|
+
**连接建立 / On connect:**
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
← { "type": "connected", "sessionId": "sess_xxx", "port": 3100 }
|
|
120
|
+
← { "type": "init", "sessionId": "sess_xxx", "model": "claude-sonnet-4-6",
|
|
121
|
+
"skills": [...], "tools": [...], "slashCommands": [...], "agents": [...] }
|
|
98
122
|
```
|
|
99
123
|
|
|
100
|
-
|
|
124
|
+
**查询响应 / Query response (streamed chunks):**
|
|
101
125
|
|
|
126
|
+
```json
|
|
127
|
+
← { "type": "text", "content": "这是一段回复..." }
|
|
128
|
+
← { "type": "thinking", "content": "模型思考过程..." }
|
|
129
|
+
← { "type": "tool_use", "name": "readFile", "input": {...}, "id": "toolu_xxx" }
|
|
130
|
+
← { "type": "done", "sessionId": "sess_xxx" }
|
|
102
131
|
```
|
|
103
|
-
→ { "prompt": "...", "model": "...", "systemPrompt": "..." }
|
|
104
|
-
← { "text": "...", "sessionId": "sess_xxx" }
|
|
105
132
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
133
|
+
**其他 / Other:**
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
← { "type": "pong", "sessionId": "sess_xxx" }
|
|
137
|
+
← { "type": "skills", "skills": [...], "tools": [...], "slashCommands": [...], "agents": [...] }
|
|
138
|
+
← { "type": "error", "content": "error message" }
|
|
109
139
|
```
|
|
110
140
|
|
|
111
|
-
###
|
|
141
|
+
### 完整示例 / Full exchange
|
|
112
142
|
|
|
113
143
|
```
|
|
114
|
-
→ { "
|
|
115
|
-
← { "
|
|
116
|
-
← { "
|
|
117
|
-
← { "id":"1", "type":"thinking", "content":"..." }
|
|
118
|
-
← { "id":"1", "type":"done", "sessionId":"..." }
|
|
144
|
+
→ { "type": "query", "prompt": "Hello" }
|
|
145
|
+
← { "type": "text", "content": "Hello! How can I help you today?" }
|
|
146
|
+
← { "type": "done", "sessionId": "sess_abc123" }
|
|
119
147
|
|
|
120
|
-
→ { "type":"ping" }
|
|
121
|
-
← { "type":"pong",
|
|
148
|
+
→ { "type": "ping" }
|
|
149
|
+
← { "type": "pong", "sessionId": "sess_abc123" }
|
|
150
|
+
```
|
|
122
151
|
|
|
123
|
-
|
|
124
|
-
← { "type":"skills", "skills":["browse",...], "tools":["Read",...], "slashCommands":[...], "agents":[...] }
|
|
152
|
+
### 单例机制 / Singleton guarantee
|
|
125
153
|
|
|
126
|
-
|
|
127
|
-
|
|
154
|
+
重复启动 `nx-ce serve` 会在同一端口上失败:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
端口 3100 已被占用 — nx-ce 单例进程已在运行中
|
|
158
|
+
Port 3100 already in use — another nx-ce instance is running
|
|
128
159
|
```
|
|
129
160
|
|
|
130
|
-
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 协议 / Protocol (stdin/stdout)
|
|
164
|
+
|
|
165
|
+
`nx-ce query` 子命令仍使用长度前缀 JSON(Chrome Native Messaging 格式):
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
[4 bytes LE uint32 = 负载长度 / payload length][UTF-8 JSON payload]
|
|
169
|
+
```
|
|
131
170
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
171
|
+
### 查询(一次性)/ Query (one-shot)
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
→ { "prompt": "...", "model": "...", "systemPrompt": "..." }
|
|
175
|
+
← { "text": "...", "sessionId": "sess_xxx" }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 带元数据输出 / With metadata
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
← { "text": "...", "sessionId": "sess_xxx",
|
|
182
|
+
"metadata": { "skills": [...], "tools": [...], "slashCommands": [...] } }
|
|
183
|
+
```
|
|
144
184
|
|
|
145
185
|
---
|
|
146
186
|
|
|
@@ -148,10 +188,8 @@ All IPC uses the same wire format as Chrome native messaging:
|
|
|
148
188
|
|
|
149
189
|
```
|
|
150
190
|
Chrome Extension / 浏览器扩展
|
|
151
|
-
↕
|
|
152
|
-
|
|
153
|
-
↕ 4B+JSON (via executor.startProcess)
|
|
154
|
-
nx-ce serve (Node.js)
|
|
191
|
+
↕ WebSocket (ws://127.0.0.1:3100)
|
|
192
|
+
nx-ce serve (Node.js) ← 单例进程 / singleton process
|
|
155
193
|
↕ @anthropic-ai/claude-agent-sdk
|
|
156
194
|
Claude Code CLI (子进程 / subprocess)
|
|
157
195
|
```
|
|
@@ -165,8 +203,6 @@ Claude Code CLI (子进程 / subprocess)
|
|
|
165
203
|
|
|
166
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.
|
|
167
205
|
|
|
168
|
-
示例状态文件 / Example state file:
|
|
169
|
-
|
|
170
206
|
```json
|
|
171
207
|
{
|
|
172
208
|
"name": "chat-tab-1",
|
|
@@ -182,9 +218,12 @@ Persisted to `~/.nx-ce/instances/{name}.json`. Each named instance stores its PI
|
|
|
182
218
|
## 开发 / Development
|
|
183
219
|
|
|
184
220
|
```bash
|
|
185
|
-
#
|
|
221
|
+
# 本地运行一次查询 / Run a one-shot query
|
|
186
222
|
node ./bin/nx-ce.js query "你好"
|
|
187
223
|
|
|
224
|
+
# 启动 WebSocket 服务 / Start WebSocket server
|
|
225
|
+
node ./bin/nx-ce.js serve --port 3100
|
|
226
|
+
|
|
188
227
|
# 检查语法 / Check syntax
|
|
189
228
|
node -c src/*.js
|
|
190
229
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nx-ce",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
],
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.159"
|
|
33
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.159",
|
|
34
|
+
"ws": "^8.21.0"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/src/cli.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function runCli() {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
case 'serve': {
|
|
86
|
-
// 持久化服务模式
|
|
86
|
+
// WebSocket 持久化服务模式
|
|
87
87
|
const name = flags.name || 'default';
|
|
88
88
|
|
|
89
89
|
const result = await startServe({
|
|
@@ -92,6 +92,7 @@ export async function runCli() {
|
|
|
92
92
|
model: flags.model,
|
|
93
93
|
cwd: flags.cwd || process.cwd(),
|
|
94
94
|
env: flags.env ? parseEnvString(flags.env) : undefined,
|
|
95
|
+
port: flags.port ? parseInt(flags.port, 10) : undefined,
|
|
95
96
|
});
|
|
96
97
|
|
|
97
98
|
return result;
|
|
@@ -132,8 +133,9 @@ export async function runCli() {
|
|
|
132
133
|
--no-persist 不持久化会话
|
|
133
134
|
--env "KEY=value,KEY2=val" 额外环境变量
|
|
134
135
|
|
|
135
|
-
nx-ce serve
|
|
136
|
+
nx-ce serve WebSocket 持久化服务器(单一进程 / 多客户端)
|
|
136
137
|
--name <name> 实例名称(默认: "default")
|
|
138
|
+
--port <port> WebSocket 端口(默认: 3100)
|
|
137
139
|
--model <id> 模型覆盖
|
|
138
140
|
--claude-path <path> Claude CLI 路径
|
|
139
141
|
--env "KEY=value,..." 额外环境变量
|
package/src/index.js
CHANGED
|
@@ -8,5 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
export { runQuery } from './query.js';
|
|
10
10
|
export { listSkills } from './skills.js';
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
readState,
|
|
13
|
+
writeState,
|
|
14
|
+
deleteState,
|
|
15
|
+
listStates,
|
|
16
|
+
LifecycleState,
|
|
17
|
+
createState,
|
|
18
|
+
} from './session-store.js';
|
|
12
19
|
export { readMessage, writeMessage } from './protocol.js';
|
|
20
|
+
export { generateId, MonotonicClock, getMachineId, formatBytes } from './util.js';
|
package/src/serve.js
CHANGED
|
@@ -1,241 +1,596 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 服务端 —
|
|
2
|
+
* 服务端 — WebSocket 持久化服务器,支持多会话管理
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 单例进程,对外提供 WebSocket 接口。
|
|
5
|
+
* 每个会话(session)拥有独立的 agentQuery()、MessageChannel 和状态文件,
|
|
6
|
+
* 天然并行,互不阻塞。
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* → { "type":"ping" }
|
|
13
|
-
* ← { "type":"pong", "sessionId":"..." }
|
|
14
|
-
* → { "type":"getSkills" }
|
|
15
|
-
* ← { "type":"skills", "skills":[...], "tools":[...], ... }
|
|
8
|
+
* 竞态保护:
|
|
9
|
+
* - session 创建:pendingCreates Map 防止重复创建
|
|
10
|
+
* - client 绑定:SDK 回复只写 session.client,不走 broadcast
|
|
11
|
+
* - state 文件:每个 session 独立文件,写锁防并发
|
|
12
|
+
* - 消息排序:每个 session 独立单调时钟
|
|
16
13
|
*/
|
|
17
14
|
|
|
15
|
+
import { WebSocketServer } from 'ws';
|
|
18
16
|
import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
19
|
-
import {
|
|
20
|
-
import { readState, writeState, deleteState } from './session-store.js';
|
|
17
|
+
import { hostname, machine, platform, release } from 'node:os';
|
|
18
|
+
import { readState, writeState, deleteState, LifecycleState, createState } from './session-store.js';
|
|
19
|
+
import { generateId, MonotonicClock, getMachineId } from './util.js';
|
|
21
20
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
21
|
+
/** 默认端口 */
|
|
22
|
+
const DEFAULT_PORT = 3100;
|
|
23
|
+
|
|
24
|
+
/** 空闲 session 超时(毫秒),超过此时间无客户端则自动关闭 */
|
|
25
|
+
const SESSION_IDLE_TIMEOUT_MS = 300_000; // 5 分钟
|
|
26
|
+
|
|
27
|
+
// =================================================================
|
|
28
|
+
// SessionManager — 管理多个独立 SDK 会话
|
|
29
|
+
// =================================================================
|
|
30
|
+
|
|
31
|
+
class SessionManager {
|
|
32
|
+
constructor(serverOptions) {
|
|
33
|
+
this.serverOptions = serverOptions; // { claudePath, model, cwd, env }
|
|
34
|
+
|
|
35
|
+
/** @type {Map<string, Session>} */
|
|
36
|
+
this.sessions = new Map();
|
|
37
|
+
|
|
38
|
+
/** 创建中的 Promise,防止并发创建同名 session */
|
|
39
|
+
this._pendingCreates = new Map();
|
|
40
|
+
|
|
41
|
+
/** 清理定时器 */
|
|
42
|
+
this._idleTimers = new Map();
|
|
43
|
+
|
|
44
|
+
/** 会话状态文件写锁 */
|
|
45
|
+
this._writeLocks = new Map();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 获取或创建一个 session。
|
|
50
|
+
* 如果另一个协程正在创建同名 session,则等待其完成。
|
|
51
|
+
*
|
|
52
|
+
* @param {string} name - session 名称(每个客户端/标签页唯一)
|
|
53
|
+
* @returns {Promise<Session>}
|
|
54
|
+
*/
|
|
55
|
+
async getOrCreate(name) {
|
|
56
|
+
// 已有活跃 session → 直接返回
|
|
57
|
+
const existing = this.sessions.get(name);
|
|
58
|
+
if (existing && !existing.closed) {
|
|
59
|
+
// 取消 idle 定时器(客户端回来了)
|
|
60
|
+
this._cancelIdleTimer(name);
|
|
61
|
+
return existing;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 正在被另一个协程创建 → 等它
|
|
65
|
+
if (this._pendingCreates.has(name)) {
|
|
66
|
+
return this._pendingCreates.get(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 创建锁 + 创建
|
|
70
|
+
const promise = this._createSession(name);
|
|
71
|
+
this._pendingCreates.set(name, promise);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return await promise;
|
|
75
|
+
} finally {
|
|
76
|
+
this._pendingCreates.delete(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 创建内部 session 结构。
|
|
82
|
+
* 注意:JS 是单线程 event loop,此函数不会被并发调用(pendingCreates 保证)。
|
|
83
|
+
*/
|
|
84
|
+
async _createSession(name) {
|
|
85
|
+
const { claudePath, model, cwd, env } = this.serverOptions;
|
|
86
|
+
|
|
87
|
+
// 检查是否有可恢复的会话状态
|
|
88
|
+
const existingState = readState(name);
|
|
89
|
+
|
|
90
|
+
// 组装 SDK 选项
|
|
91
|
+
const sdkOptions = {
|
|
92
|
+
cwd: cwd || process.cwd(),
|
|
93
|
+
model: model || 'claude-sonnet-4-6',
|
|
94
|
+
pathToClaudeCodeExecutable: claudePath,
|
|
95
|
+
permissionMode: 'bypassPermissions',
|
|
96
|
+
allowDangerouslySkipPermissions: true,
|
|
97
|
+
env: { ...process.env, ...env },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (existingState?.sessionId) {
|
|
101
|
+
sdkOptions.resume = existingState.sessionId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 消息通道 — SDK 从此处拉取下一条用户消息
|
|
105
|
+
const pendingMessages = [];
|
|
106
|
+
let resolveNext = null;
|
|
107
|
+
let turnActive = false;
|
|
108
|
+
let channelClosed = false;
|
|
109
|
+
|
|
110
|
+
const messageChannel = {
|
|
111
|
+
[Symbol.asyncIterator]() {
|
|
112
|
+
return {
|
|
113
|
+
next: () => {
|
|
114
|
+
while (pendingMessages.length > 0 && !turnActive) {
|
|
115
|
+
turnActive = true;
|
|
116
|
+
return Promise.resolve({ value: pendingMessages.shift(), done: false });
|
|
117
|
+
}
|
|
118
|
+
if (channelClosed) return Promise.resolve({ done: true, value: null });
|
|
119
|
+
return new Promise((resolve) => { resolveNext = resolve; });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function enqueueMessage(sdkUserMessage) {
|
|
126
|
+
if (resolveNext) {
|
|
127
|
+
turnActive = true;
|
|
128
|
+
const r = resolveNext;
|
|
129
|
+
resolveNext = null;
|
|
130
|
+
r({ value: sdkUserMessage, done: false });
|
|
131
|
+
} else if (pendingMessages.length < 8) {
|
|
132
|
+
pendingMessages.push(sdkUserMessage);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function onTurnComplete() {
|
|
137
|
+
turnActive = false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 启动 SDK 持久化查询
|
|
141
|
+
const response = agentQuery({ prompt: messageChannel, options: sdkOptions });
|
|
142
|
+
|
|
143
|
+
/** @type {Session} */
|
|
144
|
+
const session = {
|
|
145
|
+
name,
|
|
146
|
+
messageChannel,
|
|
147
|
+
enqueueMessage,
|
|
148
|
+
onTurnComplete,
|
|
149
|
+
channelClosed: false,
|
|
150
|
+
closeChannel() {
|
|
151
|
+
channelClosed = true;
|
|
152
|
+
if (resolveNext) {
|
|
153
|
+
const r = resolveNext;
|
|
154
|
+
resolveNext = null;
|
|
155
|
+
r({ done: true, value: null });
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
response,
|
|
160
|
+
sdkOptions,
|
|
161
|
+
existingState,
|
|
162
|
+
|
|
163
|
+
// 客户端状态
|
|
164
|
+
client: null, // 当前绑定的 WebSocket 客户端
|
|
165
|
+
queue: [], // 待处理查询 FIFO
|
|
166
|
+
turnActive: false, // SDK 是否正在处理
|
|
167
|
+
currentTurnId: null,
|
|
168
|
+
processing: false,
|
|
169
|
+
|
|
170
|
+
// 元数据
|
|
171
|
+
sessionId: existingState?.sessionId || null,
|
|
172
|
+
metadata: null, // init 消息中的 skills/tools 等
|
|
173
|
+
clock: new MonotonicClock(),
|
|
174
|
+
closed: false,
|
|
175
|
+
|
|
176
|
+
// 消费 Promise(用于等待关闭)
|
|
177
|
+
consumerPromise: null,
|
|
178
|
+
|
|
179
|
+
// usage 追踪
|
|
180
|
+
usage: existingState?.usage || {
|
|
181
|
+
inputTokens: 0,
|
|
182
|
+
outputTokens: 0,
|
|
183
|
+
cacheCreationInputTokens: 0,
|
|
184
|
+
cacheReadInputTokens: 0,
|
|
185
|
+
contextWindow: 200000,
|
|
186
|
+
contextTokens: 0,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// 后台消费 SDK 输出
|
|
191
|
+
session.consumerPromise = this._startConsumer(session);
|
|
192
|
+
|
|
193
|
+
this.sessions.set(name, session);
|
|
194
|
+
|
|
195
|
+
// 持久化初始状态
|
|
196
|
+
this._safeWriteState(session);
|
|
197
|
+
|
|
198
|
+
return session;
|
|
45
199
|
}
|
|
46
200
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
201
|
+
/**
|
|
202
|
+
* 后台消费循环 — 每个 session 独立。
|
|
203
|
+
* SDK 回复只会写入 session.client(绑定的 WS 客户端)。
|
|
204
|
+
*/
|
|
205
|
+
_startConsumer(session) {
|
|
206
|
+
return (async () => {
|
|
207
|
+
try {
|
|
208
|
+
for await (const message of session.response) {
|
|
209
|
+
// init 消息 → 捕获元数据
|
|
210
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
211
|
+
session.sessionId = message.session_id;
|
|
212
|
+
session.metadata = {
|
|
213
|
+
type: 'init',
|
|
214
|
+
sessionId: session.sessionId,
|
|
215
|
+
model: message.model,
|
|
216
|
+
skills: message.skills || [],
|
|
217
|
+
tools: message.tools || [],
|
|
218
|
+
slashCommands: message.slash_commands || [],
|
|
219
|
+
agents: message.agents || [],
|
|
220
|
+
time: session.clock.next(),
|
|
221
|
+
};
|
|
222
|
+
this._safeWriteState(session);
|
|
223
|
+
|
|
224
|
+
// 推给当前绑定的客户端
|
|
225
|
+
this._send(session.client, session.metadata);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 助手消息 → 分块转发
|
|
229
|
+
if (message.type === 'assistant' && message.message?.content) {
|
|
230
|
+
const content = message.message.content;
|
|
231
|
+
if (typeof content === 'string') {
|
|
232
|
+
this._send(session.client, { type: 'text', content, time: session.clock.next() });
|
|
233
|
+
} else if (Array.isArray(content)) {
|
|
234
|
+
for (const block of content) {
|
|
235
|
+
if (block.type === 'text') {
|
|
236
|
+
this._send(session.client, { type: 'text', content: block.text, time: session.clock.next() });
|
|
237
|
+
} else if (block.type === 'tool_use') {
|
|
238
|
+
this._send(session.client, { type: 'tool_use', name: block.name, input: block.input, id: block.id, time: session.clock.next() });
|
|
239
|
+
} else if (block.type === 'thinking') {
|
|
240
|
+
this._send(session.client, { type: 'thinking', content: block.thinking, time: session.clock.next() });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
65
244
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
245
|
+
|
|
246
|
+
// result → 回合结束
|
|
247
|
+
if (message.type === 'result') {
|
|
248
|
+
this._send(session.client, { type: 'done', sessionId: session.sessionId, time: session.clock.next() });
|
|
249
|
+
|
|
250
|
+
// usage 累积
|
|
251
|
+
if (message.usage) {
|
|
252
|
+
const u = message.usage;
|
|
253
|
+
session.usage = {
|
|
254
|
+
inputTokens: (session.usage?.inputTokens || 0) + (u.inputTokens || 0),
|
|
255
|
+
outputTokens: (session.usage?.outputTokens || 0) + (u.outputTokens || 0),
|
|
256
|
+
cacheCreationInputTokens: (session.usage?.cacheCreationInputTokens || 0) + (u.cacheCreationInputTokens || 0),
|
|
257
|
+
cacheReadInputTokens: (session.usage?.cacheReadInputTokens || 0) + (u.cacheReadInputTokens || 0),
|
|
258
|
+
contextWindow: u.contextWindow || session.usage?.contextWindow || 200000,
|
|
259
|
+
contextTokens: u.contextTokens || session.usage?.contextTokens || 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
session.onTurnComplete();
|
|
264
|
+
session.client = null; // 解绑客户端,允许下一个 query 绑定
|
|
265
|
+
session.processing = false;
|
|
266
|
+
this._safeWriteState(session);
|
|
267
|
+
|
|
268
|
+
// 异步处理队列中的下一个请求
|
|
269
|
+
setImmediate(() => this._processQueue(session));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (err?.code === 'ABORT_ERR') return;
|
|
274
|
+
this._send(session.client, { type: 'error', content: err instanceof Error ? err.message : String(err), time: session.clock.next() });
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
277
|
+
}
|
|
76
278
|
|
|
77
279
|
/**
|
|
78
|
-
*
|
|
79
|
-
* 优先直接交付给等待中的迭代器,否则放入缓冲区(最多 8 条)。
|
|
280
|
+
* 尝试处理 session 队列中的下一个查询。
|
|
80
281
|
*/
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
282
|
+
_processQueue(session) {
|
|
283
|
+
if (session.closed) return;
|
|
284
|
+
if (session.queue.length === 0 || session.processing) return;
|
|
285
|
+
|
|
286
|
+
const { client, prompt, id } = session.queue.shift();
|
|
287
|
+
session.client = client;
|
|
288
|
+
session.processing = true;
|
|
289
|
+
session.currentTurnId = generateId('turn');
|
|
290
|
+
|
|
291
|
+
this._send(session.client, {
|
|
292
|
+
type: 'turn_start',
|
|
293
|
+
turn: session.currentTurnId,
|
|
294
|
+
time: session.clock.next(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const sdkMessage = {
|
|
298
|
+
type: 'user',
|
|
299
|
+
message: { role: 'user', content: prompt },
|
|
300
|
+
session_id: session.sessionId || '',
|
|
301
|
+
uuid: generateId('msg'),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
session.enqueueMessage(sdkMessage);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 向一个 WS 客户端发 JSON(安全断开则跳过) */
|
|
308
|
+
_send(client, data) {
|
|
309
|
+
if (client && client.readyState === 1) {
|
|
310
|
+
client.send(JSON.stringify(data));
|
|
89
311
|
}
|
|
90
312
|
}
|
|
91
313
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
314
|
+
/** 持久化 session 状态(写锁防止同名并发写) */
|
|
315
|
+
_safeWriteState(session) {
|
|
316
|
+
const name = session.name;
|
|
317
|
+
// JS 单线程,用简单 flag 防同一 session 的递归写
|
|
318
|
+
writeState(name, createState(name, {
|
|
319
|
+
sessionId: session.sessionId,
|
|
320
|
+
model: session.sdkOptions.model,
|
|
321
|
+
usage: session.usage,
|
|
322
|
+
}));
|
|
98
323
|
}
|
|
99
324
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
325
|
+
/** 取消 idle 定时器 */
|
|
326
|
+
_cancelIdleTimer(name) {
|
|
327
|
+
const timer = this._idleTimers.get(name);
|
|
328
|
+
if (timer) {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
this._idleTimers.delete(name);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 安排 idle 关闭 */
|
|
335
|
+
_scheduleIdleCleanup(name) {
|
|
336
|
+
this._cancelIdleTimer(name);
|
|
337
|
+
this._idleTimers.set(name, setTimeout(() => {
|
|
338
|
+
this.destroy(name, 'idle timeout');
|
|
339
|
+
}, SESSION_IDLE_TIMEOUT_MS));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 从 session 队列中移除指定客户端的所有待处理请求。
|
|
344
|
+
*/
|
|
345
|
+
removeClientFromQueue(session, ws) {
|
|
346
|
+
if (!session || session.closed) return;
|
|
347
|
+
session.queue = session.queue.filter(item => item.client !== ws);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 销毁一个 session。
|
|
352
|
+
*/
|
|
353
|
+
async destroy(name, reason = 'shutdown') {
|
|
354
|
+
const session = this.sessions.get(name);
|
|
355
|
+
if (!session || session.closed) return;
|
|
356
|
+
session.closed = true;
|
|
357
|
+
this._cancelIdleTimer(name);
|
|
358
|
+
|
|
359
|
+
// 关闭 MessageChannel → SDK next() 返回 done
|
|
360
|
+
session.closeChannel();
|
|
361
|
+
|
|
362
|
+
// 中断 SDK 查询
|
|
363
|
+
try {
|
|
364
|
+
await session.response.interrupt();
|
|
365
|
+
} catch { /* ignore */ }
|
|
366
|
+
|
|
367
|
+
// 等待消费循环结束
|
|
368
|
+
try {
|
|
369
|
+
await session.consumerPromise;
|
|
370
|
+
} catch { /* ignore */ }
|
|
371
|
+
|
|
372
|
+
this.sessions.delete(name);
|
|
373
|
+
|
|
374
|
+
// 如果是正常关闭才清理状态文件(crash 留文件便于恢复)
|
|
375
|
+
if (reason !== 'crash') {
|
|
376
|
+
deleteState(name);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 销毁所有 session。
|
|
382
|
+
*/
|
|
383
|
+
async destroyAll(reason = 'shutdown') {
|
|
384
|
+
const names = [...this.sessions.keys()];
|
|
385
|
+
await Promise.allSettled(names.map(name => this.destroy(name, reason)));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// =================================================================
|
|
390
|
+
// 启动函数
|
|
391
|
+
// =================================================================
|
|
105
392
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
393
|
+
/**
|
|
394
|
+
* 启动 WebSocket 持久化服务。
|
|
395
|
+
*/
|
|
396
|
+
export async function startServe(options) {
|
|
397
|
+
const { name = 'default', claudePath, model, cwd, env, port = DEFAULT_PORT } = options;
|
|
109
398
|
|
|
110
|
-
|
|
399
|
+
const machineId = getMachineId();
|
|
400
|
+
const host = hostname();
|
|
401
|
+
const osInfo = `${platform()}/${release()}/${machine()}`;
|
|
402
|
+
|
|
403
|
+
// 服务器级别状态
|
|
404
|
+
const serverState = readState(name);
|
|
405
|
+
const serverSessionId = serverState?.sessionId || null;
|
|
406
|
+
|
|
407
|
+
// 创建 SessionManager
|
|
408
|
+
const sessionManager = new SessionManager({ claudePath, model, cwd, env });
|
|
409
|
+
|
|
410
|
+
// =================================================================
|
|
411
|
+
// WebSocket 服务器
|
|
412
|
+
// =================================================================
|
|
413
|
+
|
|
414
|
+
const wss = new WebSocketServer({ port, host: '127.0.0.1' });
|
|
415
|
+
|
|
416
|
+
// 等待服务器就绪
|
|
417
|
+
await new Promise((resolve, reject) => {
|
|
418
|
+
wss.once('listening', resolve);
|
|
419
|
+
wss.once('error', (err) => {
|
|
420
|
+
if (err.code === 'EADDRINUSE') {
|
|
421
|
+
console.error(`Port ${port} already in use — another nx-ce serve is running`);
|
|
422
|
+
}
|
|
423
|
+
reject(err);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// 写入服务器级状态
|
|
111
428
|
writeState(name, {
|
|
112
429
|
name,
|
|
113
430
|
pid: process.pid,
|
|
114
431
|
startedAt: new Date().toISOString(),
|
|
115
|
-
|
|
116
|
-
|
|
432
|
+
host,
|
|
433
|
+
machineId,
|
|
434
|
+
port,
|
|
435
|
+
lifecycleState: LifecycleState.RUNNING,
|
|
436
|
+
sessionCount: 0,
|
|
117
437
|
});
|
|
118
438
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
439
|
+
// 客户端连接处理
|
|
440
|
+
wss.on('connection', (ws) => {
|
|
441
|
+
// 初始连接消息
|
|
442
|
+
ws.send(JSON.stringify({
|
|
443
|
+
type: 'connected',
|
|
444
|
+
port,
|
|
445
|
+
host,
|
|
446
|
+
machineId,
|
|
447
|
+
serverTime: Date.now(),
|
|
448
|
+
}));
|
|
449
|
+
|
|
450
|
+
ws.on('message', async (raw) => {
|
|
451
|
+
let req;
|
|
452
|
+
try {
|
|
453
|
+
req = JSON.parse(raw.toString());
|
|
454
|
+
} catch {
|
|
455
|
+
ws.send(JSON.stringify({ type: 'error', content: 'invalid JSON' }));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const sessionName = req.session || 'default';
|
|
460
|
+
|
|
461
|
+
switch (req.type) {
|
|
462
|
+
case 'query': {
|
|
463
|
+
if (!req.prompt) {
|
|
464
|
+
ws.send(JSON.stringify({ type: 'error', content: 'query missing prompt' }));
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 获取或创建 session(创建锁保证并发安全)
|
|
469
|
+
let session;
|
|
470
|
+
try {
|
|
471
|
+
session = await sessionManager.getOrCreate(sessionName);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
ws.send(JSON.stringify({ type: 'error', content: `session create failed: ${err.message}` }));
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 入队
|
|
478
|
+
session.queue.push({ client: ws, prompt: req.prompt, id: req.id });
|
|
479
|
+
sessionManager._processQueue(session);
|
|
480
|
+
break;
|
|
144
481
|
}
|
|
145
482
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
writeMessage(process.stdout, { type: 'thinking', content: block.thinking });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
483
|
+
case 'ping':
|
|
484
|
+
ws.send(JSON.stringify({ type: 'pong', serverTime: Date.now() }));
|
|
485
|
+
break;
|
|
486
|
+
|
|
487
|
+
case 'getSkills': {
|
|
488
|
+
const session = sessionManager.sessions.get(sessionName);
|
|
489
|
+
if (session?.metadata) {
|
|
490
|
+
ws.send(JSON.stringify(session.metadata));
|
|
491
|
+
} else {
|
|
492
|
+
ws.send(JSON.stringify({
|
|
493
|
+
type: 'skills',
|
|
494
|
+
skills: [],
|
|
495
|
+
tools: [],
|
|
496
|
+
slashCommands: [],
|
|
497
|
+
agents: [],
|
|
498
|
+
note: 'session not yet initialized',
|
|
499
|
+
}));
|
|
166
500
|
}
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
case 'getStatus': {
|
|
505
|
+
const session = sessionManager.sessions.get(sessionName);
|
|
506
|
+
ws.send(JSON.stringify({
|
|
507
|
+
type: 'status',
|
|
508
|
+
session: sessionName,
|
|
509
|
+
sessionId: session?.sessionId || null,
|
|
510
|
+
isActive: session ? !session.closed : false,
|
|
511
|
+
queueLength: session?.queue?.length || 0,
|
|
512
|
+
processing: session?.processing || false,
|
|
513
|
+
}));
|
|
514
|
+
break;
|
|
167
515
|
}
|
|
168
516
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
517
|
+
case 'closeSession': {
|
|
518
|
+
await sessionManager.destroy(sessionName, 'client request');
|
|
519
|
+
ws.send(JSON.stringify({ type: 'session_closed', session: sessionName }));
|
|
520
|
+
break;
|
|
173
521
|
}
|
|
522
|
+
|
|
523
|
+
case 'listSessions': {
|
|
524
|
+
const sessions = [...sessionManager.sessions.entries()]
|
|
525
|
+
.filter(([_, s]) => !s.closed)
|
|
526
|
+
.map(([name, s]) => ({
|
|
527
|
+
name,
|
|
528
|
+
sessionId: s.sessionId,
|
|
529
|
+
queueLength: s.queue.length,
|
|
530
|
+
processing: s.processing,
|
|
531
|
+
}));
|
|
532
|
+
ws.send(JSON.stringify({ type: 'session_list', sessions }));
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
default:
|
|
537
|
+
ws.send(JSON.stringify({ type: 'error', content: `unknown type: ${req.type}` }));
|
|
174
538
|
}
|
|
175
|
-
}
|
|
176
|
-
if (err?.code === 'ABORT_ERR') return; // 主动中断,非错误
|
|
177
|
-
writeMessage(process.stdout, {
|
|
178
|
-
type: 'error',
|
|
179
|
-
content: err instanceof Error ? err.message : String(err),
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
})();
|
|
539
|
+
});
|
|
183
540
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
541
|
+
// 客户端断开 → 清理引用
|
|
542
|
+
ws.on('close', () => {
|
|
543
|
+
for (const [sName, session] of sessionManager.sessions) {
|
|
544
|
+
if (session.client === ws) {
|
|
545
|
+
session.client = null;
|
|
546
|
+
}
|
|
547
|
+
sessionManager.removeClientFromQueue(session, ws);
|
|
548
|
+
|
|
549
|
+
// 如果没有客户端了,安排 idle 回收
|
|
550
|
+
if (session.client === null && session.queue.length === 0 && !session.closed) {
|
|
551
|
+
sessionManager._scheduleIdleCleanup(sName);
|
|
194
552
|
}
|
|
195
|
-
break; // EOF 或解析错误 → 关闭
|
|
196
553
|
}
|
|
554
|
+
});
|
|
555
|
+
});
|
|
197
556
|
|
|
198
|
-
|
|
557
|
+
// =================================================================
|
|
558
|
+
// 信号处理(优雅关闭)
|
|
559
|
+
// =================================================================
|
|
199
560
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
role: 'user',
|
|
207
|
-
content: req.prompt,
|
|
208
|
-
},
|
|
209
|
-
session_id: currentSessionId || '',
|
|
210
|
-
uuid: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
211
|
-
};
|
|
561
|
+
async function shutdown() {
|
|
562
|
+
// 更新服务器状态
|
|
563
|
+
writeState(name, {
|
|
564
|
+
...readState(name),
|
|
565
|
+
lifecycleState: LifecycleState.STOPPED,
|
|
566
|
+
});
|
|
212
567
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
} else if (req.type === 'getSkills') {
|
|
218
|
-
// Go 端主动拉取 skill/工具/命令 列表(可重复查询)
|
|
219
|
-
writeMessage(process.stdout, sessionMetadata || {
|
|
220
|
-
type: 'skills',
|
|
221
|
-
skills: [],
|
|
222
|
-
tools: [],
|
|
223
|
-
slashCommands: [],
|
|
224
|
-
agents: [],
|
|
225
|
-
note: 'session not yet initialized',
|
|
226
|
-
});
|
|
568
|
+
// 通知所有 WS 客户端
|
|
569
|
+
wss.clients.forEach((client) => {
|
|
570
|
+
if (client.readyState === 1) {
|
|
571
|
+
client.close(1001, 'server shutting down');
|
|
227
572
|
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
try {
|
|
236
|
-
await response.interrupt();
|
|
237
|
-
} catch { /* 忽略中断错误 */ }
|
|
238
|
-
await consumerPromise;
|
|
573
|
+
});
|
|
574
|
+
wss.close();
|
|
575
|
+
|
|
576
|
+
// 关闭所有 session
|
|
577
|
+
await sessionManager.destroyAll('shutdown');
|
|
578
|
+
|
|
579
|
+
// 删除服务端状态文件
|
|
239
580
|
deleteState(name);
|
|
581
|
+
|
|
582
|
+
process.exit(0);
|
|
240
583
|
}
|
|
584
|
+
|
|
585
|
+
process.on('SIGINT', shutdown);
|
|
586
|
+
process.on('SIGTERM', shutdown);
|
|
587
|
+
|
|
588
|
+
// =================================================================
|
|
589
|
+
// 返回
|
|
590
|
+
// =================================================================
|
|
591
|
+
|
|
592
|
+
const info = { port, name };
|
|
593
|
+
console.error(`nx-ce serve ws://127.0.0.1:${port} [${name}]`);
|
|
594
|
+
|
|
595
|
+
return info;
|
|
241
596
|
}
|
package/src/session-store.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 会话存储 — 磁盘上的持久化状态
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 参考 happy 的 sessions.json 设计:
|
|
5
|
+
* - 每个命名实例存储完整元数据
|
|
6
|
+
* - lifecycleState 追踪会话生命周期
|
|
7
|
+
* - machineId/host 标识运行环境
|
|
8
|
+
* - usage 追踪 token 消耗
|
|
9
|
+
*
|
|
5
10
|
* 目录:$HOME/.nx-ce/instances/{name}.json
|
|
6
11
|
*/
|
|
7
12
|
|
|
@@ -17,6 +22,51 @@ function ensureDir() {
|
|
|
17
22
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
/**
|
|
26
|
+
* 会话生命周期状态枚举。
|
|
27
|
+
* 参考 happy 的 lifecycleState: "running" | "stopped" | "crashed"
|
|
28
|
+
*/
|
|
29
|
+
export const LifecycleState = Object.freeze({
|
|
30
|
+
RUNNING: 'running',
|
|
31
|
+
STOPPED: 'stopped',
|
|
32
|
+
CRASHED: 'crashed',
|
|
33
|
+
RESUMING: 'resuming',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 创建一个新的状态对象(含默认值)。
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name - 实例名称
|
|
40
|
+
* @param {object} [overrides] - 覆盖字段
|
|
41
|
+
* @returns {object}
|
|
42
|
+
*/
|
|
43
|
+
export function createState(name, overrides = {}) {
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
pid: process.pid,
|
|
47
|
+
startedAt: new Date().toISOString(),
|
|
48
|
+
updatedAt: new Date().toISOString(),
|
|
49
|
+
sessionId: null,
|
|
50
|
+
model: 'claude-sonnet-4-6',
|
|
51
|
+
host: '',
|
|
52
|
+
machineId: '',
|
|
53
|
+
claudeVersion: '',
|
|
54
|
+
lifecycleState: LifecycleState.RUNNING,
|
|
55
|
+
lifecycleStateSince: Date.now(),
|
|
56
|
+
startedBy: 'serve',
|
|
57
|
+
port: null,
|
|
58
|
+
usage: {
|
|
59
|
+
inputTokens: 0,
|
|
60
|
+
cacheCreationInputTokens: 0,
|
|
61
|
+
cacheReadInputTokens: 0,
|
|
62
|
+
outputTokens: 0,
|
|
63
|
+
contextWindow: 200000,
|
|
64
|
+
contextTokens: 0,
|
|
65
|
+
},
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
20
70
|
/**
|
|
21
71
|
* 读取指定实例的持久化状态。
|
|
22
72
|
*
|
|
@@ -42,7 +92,11 @@ export function readState(name) {
|
|
|
42
92
|
export function writeState(name, state) {
|
|
43
93
|
ensureDir();
|
|
44
94
|
const path = join(STATE_DIR, sanitize(name));
|
|
45
|
-
|
|
95
|
+
const enriched = {
|
|
96
|
+
...state,
|
|
97
|
+
updatedAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
writeFileSync(path, JSON.stringify(enriched, null, 2), 'utf8');
|
|
46
100
|
}
|
|
47
101
|
|
|
48
102
|
/**
|
package/src/util.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具函数 — 加密 ID 生成、单调时钟、机器标识
|
|
3
|
+
*
|
|
4
|
+
* 参考 happy 的 cuid2 + monotonic clock + machineId 设计。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
8
|
+
import { hostname, machine, platform, release } from 'node:os';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
// =================================================================
|
|
14
|
+
// ID 生成
|
|
15
|
+
// =================================================================
|
|
16
|
+
|
|
17
|
+
/** 会话 ID 前缀 */
|
|
18
|
+
const SESSION_PREFIX = 'nxce';
|
|
19
|
+
/** 消息 / turn ID 前缀 */
|
|
20
|
+
const MSG_PREFIX = 'msg';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 生成可排序的唯一 ID。
|
|
24
|
+
* 格式: {prefix}_{timestamp}-{randomUUID片段}
|
|
25
|
+
* 前缀保证可读性,时间戳保证近似排序,UUID 保证全局唯一。
|
|
26
|
+
*
|
|
27
|
+
* @param {string} prefix - ID 前缀
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function generateId(prefix = SESSION_PREFIX) {
|
|
31
|
+
const ts = Date.now().toString(36);
|
|
32
|
+
const rand = randomUUID().replace(/-/g, '').slice(0, 12);
|
|
33
|
+
return `${prefix}_${ts}_${rand}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =================================================================
|
|
37
|
+
// 单调时钟
|
|
38
|
+
// =================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 单调时钟 — 保证消息 time 字段严格递增。
|
|
42
|
+
*
|
|
43
|
+
* 参考 happy 的 AcpSessionManager.nextTime():
|
|
44
|
+
* time = max(lastTime + 1, Date.now())
|
|
45
|
+
*
|
|
46
|
+
* 即使是同一毫秒内的多条消息也能保持正确顺序。
|
|
47
|
+
*/
|
|
48
|
+
export class MonotonicClock {
|
|
49
|
+
/** @type {number} */
|
|
50
|
+
#lastTime = 0;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 获取下一个单调递增的时间戳(毫秒级)。
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
next() {
|
|
57
|
+
this.#lastTime = Math.max(this.#lastTime + 1, Date.now());
|
|
58
|
+
return this.#lastTime;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 重置时钟(会话重新初始化时使用)。
|
|
63
|
+
*/
|
|
64
|
+
reset() {
|
|
65
|
+
this.#lastTime = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// =================================================================
|
|
70
|
+
// 机器标识
|
|
71
|
+
// =================================================================
|
|
72
|
+
|
|
73
|
+
/** 持久化机器 ID 文件路径 */
|
|
74
|
+
const MACHINE_ID_FILE = join(homedir(), '.nx-ce', 'machine-id');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 获取或生成持久的机器 ID。
|
|
78
|
+
* 参考 happy 的 machineId: "418aa05c-377a-4577-b100-fd36ab54c641"
|
|
79
|
+
*
|
|
80
|
+
* 持久化到 ~/.nx-ce/machine-id,首次生成后固定不变。
|
|
81
|
+
*
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function getMachineId() {
|
|
85
|
+
try {
|
|
86
|
+
if (existsSync(MACHINE_ID_FILE)) {
|
|
87
|
+
return readFileSync(MACHINE_ID_FILE, 'utf8').trim();
|
|
88
|
+
}
|
|
89
|
+
} catch { /* 首次运行,文件不存在 */ }
|
|
90
|
+
|
|
91
|
+
// 基于机器特征生成稳定 ID
|
|
92
|
+
const raw = `${hostname()}-${machine()}-${platform()}-${release()}`;
|
|
93
|
+
const id = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
94
|
+
const formatted = [
|
|
95
|
+
id.slice(0, 8),
|
|
96
|
+
id.slice(8, 12),
|
|
97
|
+
id.slice(12, 16),
|
|
98
|
+
id.slice(16, 20),
|
|
99
|
+
id.slice(20, 24),
|
|
100
|
+
].join('-');
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(join(homedir(), '.nx-ce'), { recursive: true });
|
|
104
|
+
writeFileSync(MACHINE_ID_FILE, formatted, 'utf8');
|
|
105
|
+
} catch { /* 持久化失败不阻塞 */ }
|
|
106
|
+
|
|
107
|
+
return formatted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// =================================================================
|
|
111
|
+
// 编码 / 序列化辅助
|
|
112
|
+
// =================================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 将字节大小格式化为可读字符串。
|
|
116
|
+
* @param {number} bytes
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function formatBytes(bytes) {
|
|
120
|
+
if (bytes === 0 || !bytes) return '0 B';
|
|
121
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
122
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
123
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
124
|
+
}
|