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 CHANGED
@@ -4,12 +4,12 @@
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
6
  **nx-ce** 是一个轻量级 Node.js 适配器,封装了 `@anthropic-ai/claude-agent-sdk`。
7
- 通过长度前缀的 JSON 协议(与 Chrome Native Messaging 格式一致)在 stdin/stdout 上暴露 SDK 接口,
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 over stdin/stdout via a length-prefixed JSON protocol (identical to Chrome native messaging),
12
- supporting both one-shot cold-start queries and persistent serve sessions.
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` — 持久化管理器进程 / Persistent manager process
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
- 所有 IPC 使用与 Chrome Native Messaging 一致的线缆格式:
100
+ ### 客户端发送 / Client Server
93
101
 
94
- All IPC uses the same wire format as Chrome native messaging:
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
- [4 bytes LE uint32 = 负载长度 / payload length][UTF-8 JSON payload]
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
- ### 查询(一次性)/ Query (one-shot)
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
- # --include-metadata 时返回 metadata
107
- # With --include-metadata, response includes metadata:
108
- ← { "text": "...", "sessionId": "sess_xxx", "metadata": { "skills": [...], "tools": [...], "slashCommands": [...] } }
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
- ### 服务(持久化)/ Serve (persistent)
141
+ ### 完整示例 / Full exchange
112
142
 
113
143
  ```
114
- → { "id":"1", "type":"query", "prompt":"..." }
115
- ← { "id":"1", "type":"text", "content":"..." }
116
- ← { "id":"1", "type":"tool_use", "name":"readFile", "input":{...} }
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", "sessionId":"..." }
148
+ → { "type": "ping" }
149
+ ← { "type": "pong", "sessionId": "sess_abc123" }
150
+ ```
122
151
 
123
- { "type":"getSkills" }
124
- ← { "type":"skills", "skills":["browse",...], "tools":["Read",...], "slashCommands":[...], "agents":[...] }
152
+ ### 单例机制 / Singleton guarantee
125
153
 
126
- (首次 init 自动推送)
127
- ← { "type":"init", "skills":[...], "tools":[...], ... }
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
- 协议消息类型 / Message types:
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
- | 方向 / Dir | type | 说明 / Description |
133
- |------------|------|-------------------|
134
- | → | `query` | 用户输入 / User input |
135
- | | `text` | 文本回复 / Text response |
136
- | | `tool_use` | 工具调用请求 / Tool use request |
137
- | ← | `thinking` | 思考过程 / Model thinking |
138
- | ← | `done` | 本轮完成,含会话 ID / Turn complete with session ID |
139
- | | `error` | 错误消息 / Error message |
140
- | → | `ping` | 心跳检测 / Heartbeat |
141
- | ← | `pong` | 心跳回复 / Heartbeat response |
142
- | | `getSkills` | Go 端按需拉取技能/工具列表 / Fetch skills/tools list |
143
- | | `init` / `skills` | 技能列表回复 / Skills metadata response |
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
- Native Messaging (4B+JSON)
152
- Native Host (Go)
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
- # 本地运行 / Run locally
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",
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 持久化管理器进程(stdin/stdout)
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 { readState, writeState, deleteState, listStates } from './session-store.js';
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
- * 通过 stdin/stdout 运行,使用 4B+JSON 格式协议。
5
- * 每个实例维护一个持久化的 agentQuery() 会话。
4
+ * 单例进程,对外提供 WebSocket 接口。
5
+ * 每个会话(session)拥有独立的 agentQuery()、MessageChannel 和状态文件,
6
+ * 天然并行,互不阻塞。
6
7
  *
7
- * 协议消息(与 native_host/protocol.go 线缆格式一致):
8
- * { "id":"...", "type":"query", "prompt":"..." }
9
- * { "id":"...", "type":"text", "content":"..." }
10
- * { "id":"...", "type":"done", "sessionId":"..." }
11
- * { "id":"...", "type":"error", "content":"..." }
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 { readMessage, writeMessage } from './protocol.js';
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
- * 从 stdin 读取,向 stdout 写入,持续运行直到 stdin 关闭。
25
- */
26
- export async function startServe(options) {
27
- const { name, claudePath, model, cwd, env } = options;
28
-
29
- // 检查是否有可恢复的会话状态
30
- const existingState = readState(name);
31
-
32
- // 组装 SDK 选项
33
- const sdkOptions = {
34
- cwd: cwd || process.cwd(),
35
- model: model || 'claude-sonnet-4-6',
36
- pathToClaudeCodeExecutable: claudePath,
37
- permissionMode: 'bypassPermissions', // 跳过权限确认
38
- allowDangerouslySkipPermissions: true,
39
- env: { ...process.env, ...env },
40
- };
41
-
42
- // 如果存在之前的会话 ID,恢复会话
43
- if (existingState?.sessionId) {
44
- sdkOptions.resume = existingState.sessionId;
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
- // 简单的异步消息队列(供 SDK 侧消费方拉取)
48
- const pendingMessages = []; // 待发送消息缓冲区
49
- let resolveNext = null; // 下一轮迭代的 resolve 函数
50
- let turnActive = false; // 当前轮次是否活跃
51
- let channelClosed = false; // 通道是否已关闭
52
-
53
- // 消息通道:作为异步迭代器供 SDK 消费
54
- const messageChannel = {
55
- [Symbol.asyncIterator]() {
56
- return {
57
- next: () => {
58
- // 有缓冲消息且当前轮次空闲 → 立即返回
59
- while (pendingMessages.length > 0 && !turnActive) {
60
- turnActive = true;
61
- return Promise.resolve({
62
- value: pendingMessages.shift(),
63
- done: false,
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
- if (channelClosed) return Promise.resolve({ done: true, value: null });
68
- // 否则等待消息入队或轮次完成
69
- return new Promise((resolve) => {
70
- resolveNext = resolve;
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
- * SDK 用户消息入队。
79
- * 优先直接交付给等待中的迭代器,否则放入缓冲区(最多 8 条)。
280
+ * 尝试处理 session 队列中的下一个查询。
80
281
  */
81
- function enqueueMessage(sdkUserMessage) {
82
- if (resolveNext) {
83
- turnActive = true;
84
- const r = resolveNext;
85
- resolveNext = null;
86
- r({ value: sdkUserMessage, done: false });
87
- } else if (pendingMessages.length < 8) {
88
- pendingMessages.push(sdkUserMessage);
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
- function onTurnComplete() {
94
- turnActive = false;
95
- const r = resolveNext;
96
- resolveNext = null;
97
- if (r) r({ done: true, value: null });
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
- const response = agentQuery({
102
- prompt: messageChannel,
103
- options: sdkOptions,
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
- let currentSessionId = existingState?.sessionId || null;
107
- // 缓存 session 元数据,供 getSkills 按需查询
108
- let sessionMetadata = null;
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
- sessionId: currentSessionId,
116
- model: sdkOptions.model,
432
+ host,
433
+ machineId,
434
+ port,
435
+ lifecycleState: LifecycleState.RUNNING,
436
+ sessionCount: 0,
117
437
  });
118
438
 
119
- // 后台任务:消费 SDK 输出并写入 stdout
120
- const consumerPromise = (async () => {
121
- try {
122
- for await (const message of response) {
123
- // 捕获初始化消息中的会话 ID + 元数据,更新持久化状态
124
- if (message.type === 'system' && message.subtype === 'init' && message.session_id) {
125
- currentSessionId = message.session_id;
126
- writeState(name, {
127
- name,
128
- pid: process.pid,
129
- startedAt: new Date().toISOString(),
130
- sessionId: currentSessionId,
131
- model: sdkOptions.model,
132
- });
133
- // 将技能/工具/命令列表转发给客户端 + 缓存供 getSkills 查询
134
- sessionMetadata = {
135
- type: 'init',
136
- sessionId: currentSessionId,
137
- model: message.model,
138
- skills: message.skills || [],
139
- tools: message.tools || [],
140
- slashCommands: message.slash_commands || [],
141
- agents: message.agents || [],
142
- };
143
- writeMessage(process.stdout, sessionMetadata);
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
- // 助手消息 → 区分为 text / tool_use / thinking 块写入 stdout
147
- if (message.type === 'assistant' && message.message?.content) {
148
- const content = message.message.content;
149
- if (typeof content === 'string') {
150
- writeMessage(process.stdout, { type: 'text', content });
151
- } else if (Array.isArray(content)) {
152
- for (const block of content) {
153
- if (block.type === 'text') {
154
- writeMessage(process.stdout, { type: 'text', content: block.text });
155
- } else if (block.type === 'tool_use') {
156
- writeMessage(process.stdout, {
157
- type: 'tool_use',
158
- name: block.name,
159
- input: block.input,
160
- id: block.id,
161
- });
162
- } else if (block.type === 'thinking') {
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
- // result 消息表示当前轮次完成
170
- if (message.type === 'result') {
171
- writeMessage(process.stdout, { type: 'done', sessionId: currentSessionId });
172
- onTurnComplete();
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
- } catch (err) {
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
- // 主循环:从 stdin 读取协议消息,转发给 SDK
185
- try {
186
- while (true) {
187
- let req;
188
- try {
189
- req = await readMessage(process.stdin);
190
- } catch (err) {
191
- if (err?.message?.startsWith?.('message too large')) {
192
- writeMessage(process.stdout, { type: 'error', content: 'message too large' });
193
- continue;
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
- if (!req) break; // 正常 EOF
557
+ // =================================================================
558
+ // 信号处理(优雅关闭)
559
+ // =================================================================
199
560
 
200
- // 根据消息类型路由
201
- if (req.type === 'query' && req.prompt) {
202
- // 构建 SDK 用户消息
203
- const sdkMessage = {
204
- type: 'user',
205
- message: {
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
- enqueueMessage(sdkMessage);
214
- } else if (req.type === 'ping') {
215
- // ping/pong 心跳
216
- writeMessage(process.stdout, { type: 'pong', sessionId: currentSessionId });
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
- } finally {
230
- // 清理:关闭通道、中断查询、等待消费完成、删除状态文件
231
- channelClosed = true;
232
- if (resolveNext) {
233
- resolveNext({ done: true, value: null });
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
  }
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * 会话存储 — 磁盘上的持久化状态
3
3
  *
4
- * 模式:nx-sxwriteState/readState。
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
- writeFileSync(path, JSON.stringify(state, null, 2), 'utf8');
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
+ }