nx-ce 0.1.0
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 +180 -0
- package/bin/nx-ce.js +23 -0
- package/package.json +35 -0
- package/src/cli.js +179 -0
- package/src/index.js +11 -0
- package/src/protocol.js +115 -0
- package/src/query.js +120 -0
- package/src/serve.js +216 -0
- package/src/session-store.js +88 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# nx-ce — Claude Engine
|
|
2
|
+
|
|
3
|
+
**nx-ce** 是一个轻量级 Node.js 适配器,封装了 `@anthropic-ai/claude-agent-sdk`。
|
|
4
|
+
通过长度前缀的 JSON 协议(与 Chrome Native Messaging 格式一致)在 stdin/stdout 上暴露 SDK 接口,
|
|
5
|
+
支持一次性冷启动查询与持久化服务两种运行模式。
|
|
6
|
+
|
|
7
|
+
**nx-ce** is a lightweight Node.js adapter for `@anthropic-ai/claude-agent-sdk`.
|
|
8
|
+
It exposes the SDK over stdin/stdout via a length-prefixed JSON protocol (identical to Chrome native messaging),
|
|
9
|
+
supporting both one-shot cold-start queries and persistent serve sessions.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 项目家族 / Family
|
|
14
|
+
|
|
15
|
+
| Package | 角色 / Role |
|
|
16
|
+
|---------|-------------|
|
|
17
|
+
| **nx-ce** | Claude Engine — SDK 适配层 / SDK adapter layer |
|
|
18
|
+
| [nx-sx](https://github.com/jokelx/nx-sx) | Sandbox eXecution — 窗口/终端管理器 / window & terminal manager |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 安装 / Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install nx-ce
|
|
26
|
+
# 或全局安装 / or install globally
|
|
27
|
+
npm install -g nx-ce
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 命令行用法 / CLI Usage
|
|
33
|
+
|
|
34
|
+
### `nx-ce query <prompt>` — 一次性冷启动查询 / One-shot cold-start query
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
nx-ce query "解释这段代码" --model claude-sonnet-4-6
|
|
38
|
+
nx-ce query "Explain this code" --model claude-haiku-4-5 --no-persist
|
|
39
|
+
nx-ce query "继续之前的对话" --resume sess_abc123
|
|
40
|
+
nx-ce query "Analyze" --skill git-workflow,code-review
|
|
41
|
+
nx-ce query "Analyze" --skill all
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| 选项 / Flag | 说明 / Description |
|
|
45
|
+
|-------------|-------------------|
|
|
46
|
+
| `--model <id>` | 模型 ID 覆盖(默认 `claude-sonnet-4-6`)/ Model override |
|
|
47
|
+
| `--claude-path <path>` | Claude CLI 可执行文件路径 / Path to Claude CLI binary |
|
|
48
|
+
| `--system-prompt <text>` | 系统提示词覆盖 / System prompt override |
|
|
49
|
+
| `--resume <sessionId>` | 续接之前的会话(长对话)/ Resume a prior session |
|
|
50
|
+
| `--skill <name>[,<name>...]` | 加载指定 Skill(逗号分隔,传 `all` 加载全部)/ Load specific skills |
|
|
51
|
+
| `--no-persist` | 不持久化会话 / Don't persist session |
|
|
52
|
+
| `--env "KEY=value,KEY2=val"` | 额外环境变量 / Extra environment variables |
|
|
53
|
+
|
|
54
|
+
### `nx-ce serve` — 持久化管理器进程 / Persistent manager process
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
nx-ce serve --name chat-tab-1
|
|
58
|
+
nx-ce serve --name default --model claude-sonnet-4-6
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
通过 stdin/stdout 接收 4B+JSON 协议消息,保持一个持久化的 SDK 会话。
|
|
62
|
+
Reads/writes 4B+JSON protocol messages over stdin/stdout, maintaining a persistent SDK session.
|
|
63
|
+
|
|
64
|
+
| 选项 / Flag | 说明 / Description |
|
|
65
|
+
|-------------|-------------------|
|
|
66
|
+
| `--name <name>` | 实例名称(默认 `"default"`)/ Instance name |
|
|
67
|
+
| `--model <id>` | 模型 ID 覆盖 / Model override |
|
|
68
|
+
| `--claude-path <path>` | Claude CLI 可执行文件路径 / Path to Claude CLI binary |
|
|
69
|
+
| `--env "KEY=value,..."` | 额外环境变量 / Extra environment variables |
|
|
70
|
+
|
|
71
|
+
### `nx-ce status` — 查看实例状态 / Show instance state
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
nx-ce status # 列出所有实例 / List all instances
|
|
75
|
+
nx-ce status --name chat-tab-1 # 查看指定实例 / Show specific instance
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `nx-ce help` — 显示帮助 / Show help
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
nx-ce help
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 协议 / Protocol
|
|
87
|
+
|
|
88
|
+
所有 IPC 使用与 Chrome Native Messaging 一致的线缆格式:
|
|
89
|
+
|
|
90
|
+
All IPC uses the same wire format as Chrome native messaging:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
[4 bytes LE uint32 = 负载长度 / payload length][UTF-8 JSON payload]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 查询(一次性)/ Query (one-shot)
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
→ { "prompt": "...", "model": "...", "systemPrompt": "..." }
|
|
100
|
+
← { "text": "...", "sessionId": "sess_xxx" }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 服务(持久化)/ Serve (persistent)
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
→ { "id":"1", "type":"query", "prompt":"..." }
|
|
107
|
+
← { "id":"1", "type":"text", "content":"..." }
|
|
108
|
+
← { "id":"1", "type":"tool_use", "name":"readFile", "input":{...} }
|
|
109
|
+
← { "id":"1", "type":"thinking", "content":"..." }
|
|
110
|
+
← { "id":"1", "type":"done", "sessionId":"..." }
|
|
111
|
+
|
|
112
|
+
→ { "type":"ping" }
|
|
113
|
+
← { "type":"pong", "sessionId":"..." }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
协议消息类型 / Message types:
|
|
117
|
+
|
|
118
|
+
| 方向 / Dir | type | 说明 / Description |
|
|
119
|
+
|------------|------|-------------------|
|
|
120
|
+
| → | `query` | 用户输入 / User input |
|
|
121
|
+
| ← | `text` | 文本回复 / Text response |
|
|
122
|
+
| ← | `tool_use` | 工具调用请求 / Tool use request |
|
|
123
|
+
| ← | `thinking` | 思考过程 / Model thinking |
|
|
124
|
+
| ← | `done` | 本轮完成,含会话 ID / Turn complete with session ID |
|
|
125
|
+
| ← | `error` | 错误消息 / Error message |
|
|
126
|
+
| → | `ping` | 心跳检测 / Heartbeat |
|
|
127
|
+
| ← | `pong` | 心跳回复 / Heartbeat response |
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 架构 / Architecture
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
Chrome Extension / 浏览器扩展
|
|
135
|
+
↕ Native Messaging (4B+JSON)
|
|
136
|
+
Native Host (Go)
|
|
137
|
+
↕ 4B+JSON (via executor.startProcess)
|
|
138
|
+
nx-ce serve (Node.js)
|
|
139
|
+
↕ @anthropic-ai/claude-agent-sdk
|
|
140
|
+
Claude Code CLI (子进程 / subprocess)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 状态持久化 / State
|
|
146
|
+
|
|
147
|
+
状态持久化到 `~/.nx-ce/instances/{name}.json`。
|
|
148
|
+
每个命名实例存储其 PID、会话 ID 和启动时间,用于崩溃恢复和会话续接。
|
|
149
|
+
|
|
150
|
+
Persisted to `~/.nx-ce/instances/{name}.json`. Each named instance stores its PID, session ID, and start time for crash recovery and session resumption.
|
|
151
|
+
|
|
152
|
+
示例状态文件 / Example state file:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"name": "chat-tab-1",
|
|
157
|
+
"pid": 12345,
|
|
158
|
+
"startedAt": "2026-06-06T10:30:00.000Z",
|
|
159
|
+
"sessionId": "sess_abc123",
|
|
160
|
+
"model": "claude-sonnet-4-6"
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 开发 / Development
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# 本地运行 / Run locally
|
|
170
|
+
node ./bin/nx-ce.js query "你好"
|
|
171
|
+
|
|
172
|
+
# 检查语法 / Check syntax
|
|
173
|
+
node -c src/*.js
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## License / 许可证
|
|
179
|
+
|
|
180
|
+
MIT
|
package/bin/nx-ce.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* nx-ce — Claude Engine
|
|
5
|
+
*
|
|
6
|
+
* CLI entry point. Routes to subcommands:
|
|
7
|
+
* nx-ce query "prompt" — one-shot cold-start query
|
|
8
|
+
* nx-ce serve — persistent manager process (stdin/stdout protocol)
|
|
9
|
+
* nx-ce status — show instance state
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { runCli } from '../src/cli.js';
|
|
13
|
+
|
|
14
|
+
runCli()
|
|
15
|
+
.then((result) => {
|
|
16
|
+
if (result) {
|
|
17
|
+
console.log(JSON.stringify(result));
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
.catch((error) => {
|
|
21
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nx-ce",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"nx-ce": "bin/nx-ce.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"nx-ce": "node ./bin/nx-ce.js",
|
|
20
|
+
"test": "node test/basic.js",
|
|
21
|
+
"start": "node ./bin/nx-ce.js"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"claude",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"anthropic",
|
|
27
|
+
"sdk",
|
|
28
|
+
"native-messaging",
|
|
29
|
+
"chrome-extension"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.159"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI — 子命令路由器
|
|
3
|
+
*
|
|
4
|
+
* 模式:nx-sx/src/cli.js
|
|
5
|
+
* 路由到 query | serve | status | help
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { runQuery } from './query.js';
|
|
10
|
+
import { startServe } from './serve.js';
|
|
11
|
+
import { readState, listStates } from './session-store.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 解析命令行参数。
|
|
15
|
+
* 支持 --key=value 和 --key value 两种格式。
|
|
16
|
+
*
|
|
17
|
+
* @param {string[]} argv - 命令行参数数组(默认 process.argv.slice(2))
|
|
18
|
+
* @returns {{ cmd: string, flags: object, args: string[] }}
|
|
19
|
+
*/
|
|
20
|
+
export function parseArgs(argv = process.argv.slice(2)) {
|
|
21
|
+
const cmd = argv[0]; // 第一个参数为子命令
|
|
22
|
+
const rest = argv.slice(1);
|
|
23
|
+
|
|
24
|
+
// 解析 --key=value 或 --key value 选项
|
|
25
|
+
// positional 只收集非 -- 开头的参数
|
|
26
|
+
const flags = {};
|
|
27
|
+
const positional = [];
|
|
28
|
+
for (let i = 0; i < rest.length; i++) {
|
|
29
|
+
const arg = rest[i];
|
|
30
|
+
if (arg.startsWith('--')) {
|
|
31
|
+
const eqIdx = arg.indexOf('=');
|
|
32
|
+
if (eqIdx !== -1) {
|
|
33
|
+
// --key=value 格式
|
|
34
|
+
flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
|
|
35
|
+
} else {
|
|
36
|
+
// --key value 格式(下一个参数作为值)
|
|
37
|
+
flags[arg.slice(2)] = rest[++i] ?? true;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
positional.push(arg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { cmd, flags, args: positional };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 运行 CLI 入口。
|
|
49
|
+
* 根据子命令分发到对应的处理函数。
|
|
50
|
+
*/
|
|
51
|
+
export async function runCli() {
|
|
52
|
+
const { cmd, flags, args } = parseArgs();
|
|
53
|
+
|
|
54
|
+
switch (cmd) {
|
|
55
|
+
case 'query': {
|
|
56
|
+
// 冷启动查询
|
|
57
|
+
const prompt = args[0] || flags.prompt;
|
|
58
|
+
if (!prompt) {
|
|
59
|
+
throw new Error('用法: nx-ce query <prompt> [--model ...] [--claude-path ...]');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = await runQuery({
|
|
63
|
+
prompt,
|
|
64
|
+
model: flags.model,
|
|
65
|
+
cwd: flags.cwd || process.cwd(),
|
|
66
|
+
claudePath: resolveClaudePath(flags['claude-path']),
|
|
67
|
+
systemPrompt: flags['system-prompt'],
|
|
68
|
+
persistSession: flags['no-persist'] ? false : undefined,
|
|
69
|
+
resumeSessionId: flags.resume,
|
|
70
|
+
skills: parseSkills(flags.skill),
|
|
71
|
+
env: flags.env ? parseEnvString(flags.env) : undefined,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'serve': {
|
|
78
|
+
// 持久化服务模式
|
|
79
|
+
const name = flags.name || 'default';
|
|
80
|
+
|
|
81
|
+
const result = await startServe({
|
|
82
|
+
name,
|
|
83
|
+
claudePath: resolveClaudePath(flags['claude-path']),
|
|
84
|
+
model: flags.model,
|
|
85
|
+
cwd: flags.cwd || process.cwd(),
|
|
86
|
+
env: flags.env ? parseEnvString(flags.env) : undefined,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case 'status': {
|
|
93
|
+
// 查询实例状态
|
|
94
|
+
const name = flags.name;
|
|
95
|
+
if (name) {
|
|
96
|
+
return readState(name); // 查询指定实例
|
|
97
|
+
}
|
|
98
|
+
return listStates(); // 列出所有实例
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case 'help':
|
|
102
|
+
default:
|
|
103
|
+
// 显示帮助信息
|
|
104
|
+
console.log(`
|
|
105
|
+
nx-ce — Claude Engine
|
|
106
|
+
|
|
107
|
+
用法:
|
|
108
|
+
nx-ce query <prompt> 一次性冷启动查询
|
|
109
|
+
--model <id> 模型覆盖
|
|
110
|
+
--claude-path <path> Claude CLI 路径
|
|
111
|
+
--system-prompt <text> 系统提示词覆盖
|
|
112
|
+
--resume <sessionId> 续接之前的会话(长对话)
|
|
113
|
+
--skill <name>[,<name>...] 加载指定 Skill(逗号分隔,传 "all" 加载全部)
|
|
114
|
+
--no-persist 不持久化会话
|
|
115
|
+
--env "KEY=value,KEY2=val" 额外环境变量
|
|
116
|
+
|
|
117
|
+
nx-ce serve 持久化管理器进程(stdin/stdout)
|
|
118
|
+
--name <name> 实例名称(默认: "default")
|
|
119
|
+
--model <id> 模型覆盖
|
|
120
|
+
--claude-path <path> Claude CLI 路径
|
|
121
|
+
--env "KEY=value,..." 额外环境变量
|
|
122
|
+
|
|
123
|
+
nx-ce status [--name <name>] 查看实例状态
|
|
124
|
+
|
|
125
|
+
nx-ce help 显示此帮助
|
|
126
|
+
`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 解析 Claude CLI 路径。
|
|
133
|
+
* 优先使用命令行参数,然后检查环境变量。
|
|
134
|
+
*
|
|
135
|
+
* @param {string|undefined} flag - 命令行传入的路径
|
|
136
|
+
* @returns {string|undefined} 解析后的路径,未找到则返回 undefined(由 SDK 自动检测)
|
|
137
|
+
*/
|
|
138
|
+
function resolveClaudePath(flag) {
|
|
139
|
+
if (flag) return flag;
|
|
140
|
+
// 常见位置
|
|
141
|
+
const candidates = [
|
|
142
|
+
process.env.CLAUDE_PATH,
|
|
143
|
+
process.env.CLAUDE_CLI_PATH,
|
|
144
|
+
// Windows: npx、npm 全局或用户本地安装
|
|
145
|
+
];
|
|
146
|
+
for (const c of candidates) {
|
|
147
|
+
if (c && existsSync(c)) return c;
|
|
148
|
+
}
|
|
149
|
+
// 未找到,让 SDK 自动检测
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 解析环境变量字符串为对象。
|
|
155
|
+
* 格式: "KEY=value,KEY2=val2"
|
|
156
|
+
*
|
|
157
|
+
* @param {string} str - 逗号分隔的 KEY=value 对
|
|
158
|
+
* @returns {object}
|
|
159
|
+
*/
|
|
160
|
+
function parseEnvString(str) {
|
|
161
|
+
const result = {};
|
|
162
|
+
for (const pair of str.split(',')) {
|
|
163
|
+
const eqIdx = pair.indexOf('=');
|
|
164
|
+
if (eqIdx !== -1) {
|
|
165
|
+
result[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 解析 --skill 参数。
|
|
173
|
+
* 逗号分隔的列表 → 数组;"all" → "all"(由 SDK 处理)。
|
|
174
|
+
*/
|
|
175
|
+
function parseSkills(value) {
|
|
176
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
177
|
+
if (value === 'all' || value === 'ALL') return 'all';
|
|
178
|
+
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
179
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nx-ce — 公开 API 入口
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* import { runQuery } from 'nx-ce';
|
|
6
|
+
* const { text, sessionId } = await runQuery({ prompt: 'hello', ... });
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { runQuery } from './query.js';
|
|
10
|
+
export { readState, writeState, deleteState, listStates } from './session-store.js';
|
|
11
|
+
export { readMessage, writeMessage } from './protocol.js';
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议 — 长度前缀的 JSON 消息通信
|
|
3
|
+
*
|
|
4
|
+
* 线缆格式(与 native_host/internal/protocol/protocol.go 一致):
|
|
5
|
+
* [4 字节 LE uint32 = 载荷长度][UTF-8 JSON 载荷]
|
|
6
|
+
*
|
|
7
|
+
* 这是 nx-ce 与原生主机之间的唯一通信契约。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Buffer } from 'node:buffer';
|
|
11
|
+
|
|
12
|
+
/** 单条消息最大字节数(10 MB,与 Go 端对齐) */
|
|
13
|
+
const MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 从可读流中精确读取 n 个字节。
|
|
17
|
+
*
|
|
18
|
+
* @param {import('stream').Readable} stream - 可读流
|
|
19
|
+
* @param {number} n - 需要读取的字节数
|
|
20
|
+
* @returns {Promise<Buffer|null>} 成功返回 Buffer,正常 EOF 返回 null,出错则 reject
|
|
21
|
+
*/
|
|
22
|
+
function readExactly(stream, n) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let remaining = n;
|
|
26
|
+
|
|
27
|
+
/** 尝试从流中同步读取数据 */
|
|
28
|
+
function tryRead() {
|
|
29
|
+
while (remaining > 0) {
|
|
30
|
+
const chunk = stream.read(remaining);
|
|
31
|
+
if (chunk === null) {
|
|
32
|
+
// 暂无数据 — 等待 readable 或 end 事件
|
|
33
|
+
waitForData();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
remaining -= chunk.length;
|
|
38
|
+
}
|
|
39
|
+
// 已读取全部所需字节
|
|
40
|
+
cleanup();
|
|
41
|
+
resolve(Buffer.concat(chunks));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 注册异步等待回调 */
|
|
45
|
+
function waitForData() {
|
|
46
|
+
// 流已结束 → 信号 EOF
|
|
47
|
+
if (stream.readableEnded || stream.destroyed) {
|
|
48
|
+
cleanup();
|
|
49
|
+
resolve(null);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
stream.once('readable', tryRead);
|
|
53
|
+
stream.once('end', onEnd);
|
|
54
|
+
stream.once('error', onError);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onEnd() {
|
|
58
|
+
cleanup();
|
|
59
|
+
resolve(null);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function onError(err) {
|
|
63
|
+
cleanup();
|
|
64
|
+
reject(err);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 清理注册的事件监听器 */
|
|
68
|
+
function cleanup() {
|
|
69
|
+
stream.removeListener('readable', tryRead);
|
|
70
|
+
stream.removeListener('end', onEnd);
|
|
71
|
+
stream.removeListener('error', onError);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
tryRead();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 从可读流中读取一条消息。
|
|
80
|
+
*
|
|
81
|
+
* @param {import('stream').Readable} stream - 可读流
|
|
82
|
+
* @returns {Promise<object|null>} 解析后的 JSON 对象,正常 EOF 返回 null
|
|
83
|
+
*/
|
|
84
|
+
export async function readMessage(stream) {
|
|
85
|
+
const headerBuf = await readExactly(stream, 4);
|
|
86
|
+
if (headerBuf === null) return null; // 正常 EOF
|
|
87
|
+
|
|
88
|
+
const length = headerBuf.readUInt32LE(0); // 小端序解析载荷长度
|
|
89
|
+
|
|
90
|
+
if (length > MAX_MESSAGE_SIZE) {
|
|
91
|
+
throw new Error(`message too large: ${length}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const payloadBuf = await readExactly(stream, length);
|
|
95
|
+
if (payloadBuf === null) {
|
|
96
|
+
throw new Error('unexpected EOF during message payload');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return JSON.parse(payloadBuf.toString('utf8'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 向可写流中写入一条消息。
|
|
104
|
+
*
|
|
105
|
+
* @param {import('stream').Writable} stream - 可写流
|
|
106
|
+
* @param {object} data - 要发送的数据对象(会被 JSON 序列化)
|
|
107
|
+
*/
|
|
108
|
+
export function writeMessage(stream, data) {
|
|
109
|
+
const payload = Buffer.from(JSON.stringify(data), 'utf8');
|
|
110
|
+
const header = Buffer.alloc(4);
|
|
111
|
+
header.writeUInt32LE(payload.length, 0); // 小端序写入载荷长度
|
|
112
|
+
|
|
113
|
+
stream.write(header);
|
|
114
|
+
stream.write(payload);
|
|
115
|
+
}
|
package/src/query.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 冷启动查询 — 对 @anthropic-ai/claude-agent-sdk 的一次性调用
|
|
3
|
+
*
|
|
4
|
+
* 参考 claudian 的 claudeColdStartQuery.ts(简单的非持久化路径)。
|
|
5
|
+
* 不含 MessageChannel、流式消费循环或 Electron 兼容代码。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 执行一次冷启动查询并返回完整的文本结果。
|
|
12
|
+
*
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {string} options.prompt - 用户提示词
|
|
15
|
+
* @param {string} [options.systemPrompt] - 系统提示词覆盖
|
|
16
|
+
* @param {string} [options.model] - 模型 ID 覆盖
|
|
17
|
+
* @param {string} options.cwd - 工作目录
|
|
18
|
+
* @param {string} options.claudePath - Claude CLI 可执行文件路径
|
|
19
|
+
* @param {object} [options.env] - 额外的环境变量
|
|
20
|
+
* @param {string[]} [options.tools] - 工具白名单(省略则使用 SDK 默认值)
|
|
21
|
+
* @param {boolean} [options.persistSession] - 是否持久化会话(默认 true)
|
|
22
|
+
* @param {string} [options.resumeSessionId] - 恢复之前的会话
|
|
23
|
+
* @param {string[]|'all'} [options.skills] - 加载哪些 Skill(数组或 'all')
|
|
24
|
+
* @param {AbortController} [options.signal] - 中止信号
|
|
25
|
+
* @returns {Promise<{ text: string, sessionId: string | null }>}
|
|
26
|
+
*/
|
|
27
|
+
export async function runQuery(options) {
|
|
28
|
+
const {
|
|
29
|
+
prompt,
|
|
30
|
+
systemPrompt,
|
|
31
|
+
model,
|
|
32
|
+
cwd,
|
|
33
|
+
claudePath,
|
|
34
|
+
env = {},
|
|
35
|
+
tools,
|
|
36
|
+
persistSession,
|
|
37
|
+
resumeSessionId,
|
|
38
|
+
skills,
|
|
39
|
+
signal,
|
|
40
|
+
} = options;
|
|
41
|
+
|
|
42
|
+
// 组装 SDK 选项
|
|
43
|
+
const sdkOptions = {
|
|
44
|
+
cwd: cwd || process.cwd(),
|
|
45
|
+
model: model || 'claude-sonnet-4-6',
|
|
46
|
+
pathToClaudeCodeExecutable: claudePath,
|
|
47
|
+
permissionMode: 'bypassPermissions', // 跳过权限确认
|
|
48
|
+
allowDangerouslySkipPermissions: true,
|
|
49
|
+
env: {
|
|
50
|
+
...process.env,
|
|
51
|
+
...env, // 合并额外环境变量
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// 以下为可选参数的条件注入
|
|
56
|
+
if (systemPrompt) {
|
|
57
|
+
sdkOptions.systemPrompt = systemPrompt;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (tools !== undefined) {
|
|
61
|
+
sdkOptions.tools = tools;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (persistSession === false) {
|
|
65
|
+
sdkOptions.persistSession = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (resumeSessionId) {
|
|
69
|
+
sdkOptions.resume = resumeSessionId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (skills !== undefined) {
|
|
73
|
+
sdkOptions.skills = skills;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (signal) {
|
|
77
|
+
sdkOptions.abortController = signal;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 确保总有一个 abort controller
|
|
81
|
+
const abortController = signal || new AbortController();
|
|
82
|
+
sdkOptions.abortController = abortController;
|
|
83
|
+
|
|
84
|
+
// 发起 SDK 查询(返回异步迭代器)
|
|
85
|
+
const response = agentQuery({ prompt, options: sdkOptions });
|
|
86
|
+
|
|
87
|
+
let text = '';
|
|
88
|
+
let sessionId = null;
|
|
89
|
+
|
|
90
|
+
// 遍历 SDK 返回的流式消息
|
|
91
|
+
for await (const message of response) {
|
|
92
|
+
// 如果已中止,中断查询
|
|
93
|
+
if (abortController.signal.aborted) {
|
|
94
|
+
await response.interrupt();
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 从初始化消息中捕获会话 ID
|
|
99
|
+
if (message.type === 'system' && message.subtype === 'init' && message.session_id) {
|
|
100
|
+
sessionId = message.session_id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 提取助手的文本回复内容
|
|
104
|
+
if (message.type === 'assistant' && message.message?.content) {
|
|
105
|
+
const content = message.message.content;
|
|
106
|
+
if (typeof content === 'string') {
|
|
107
|
+
text += content;
|
|
108
|
+
} else if (Array.isArray(content)) {
|
|
109
|
+
// 内容块数组,筛选出 text 块
|
|
110
|
+
for (const block of content) {
|
|
111
|
+
if (block.type === 'text') {
|
|
112
|
+
text += block.text;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { text, sessionId };
|
|
120
|
+
}
|
package/src/serve.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 服务端 — 持久化管理器进程
|
|
3
|
+
*
|
|
4
|
+
* 通过 stdin/stdout 运行,使用 4B+JSON 格式协议。
|
|
5
|
+
* 每个实例维护一个持久化的 agentQuery() 会话。
|
|
6
|
+
*
|
|
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
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
17
|
+
import { readMessage, writeMessage } from './protocol.js';
|
|
18
|
+
import { readState, writeState, deleteState } from './session-store.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 启动一个持久化服务会话。
|
|
22
|
+
* 从 stdin 读取,向 stdout 写入,持续运行直到 stdin 关闭。
|
|
23
|
+
*/
|
|
24
|
+
export async function startServe(options) {
|
|
25
|
+
const { name, claudePath, model, cwd, env } = options;
|
|
26
|
+
|
|
27
|
+
// 检查是否有可恢复的会话状态
|
|
28
|
+
const existingState = readState(name);
|
|
29
|
+
|
|
30
|
+
// 组装 SDK 选项
|
|
31
|
+
const sdkOptions = {
|
|
32
|
+
cwd: cwd || process.cwd(),
|
|
33
|
+
model: model || 'claude-sonnet-4-6',
|
|
34
|
+
pathToClaudeCodeExecutable: claudePath,
|
|
35
|
+
permissionMode: 'bypassPermissions', // 跳过权限确认
|
|
36
|
+
allowDangerouslySkipPermissions: true,
|
|
37
|
+
env: { ...process.env, ...env },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// 如果存在之前的会话 ID,恢复会话
|
|
41
|
+
if (existingState?.sessionId) {
|
|
42
|
+
sdkOptions.resume = existingState.sessionId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 简单的异步消息队列(供 SDK 侧消费方拉取)
|
|
46
|
+
const pendingMessages = []; // 待发送消息缓冲区
|
|
47
|
+
let resolveNext = null; // 下一轮迭代的 resolve 函数
|
|
48
|
+
let turnActive = false; // 当前轮次是否活跃
|
|
49
|
+
let channelClosed = false; // 通道是否已关闭
|
|
50
|
+
|
|
51
|
+
// 消息通道:作为异步迭代器供 SDK 消费
|
|
52
|
+
const messageChannel = {
|
|
53
|
+
[Symbol.asyncIterator]() {
|
|
54
|
+
return {
|
|
55
|
+
next: () => {
|
|
56
|
+
// 有缓冲消息且当前轮次空闲 → 立即返回
|
|
57
|
+
while (pendingMessages.length > 0 && !turnActive) {
|
|
58
|
+
turnActive = true;
|
|
59
|
+
return Promise.resolve({
|
|
60
|
+
value: pendingMessages.shift(),
|
|
61
|
+
done: false,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// 通道已关闭 → 结束迭代
|
|
65
|
+
if (channelClosed) return Promise.resolve({ done: true, value: null });
|
|
66
|
+
// 否则等待消息入队或轮次完成
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
resolveNext = resolve;
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 将 SDK 用户消息入队。
|
|
77
|
+
* 优先直接交付给等待中的迭代器,否则放入缓冲区(最多 8 条)。
|
|
78
|
+
*/
|
|
79
|
+
function enqueueMessage(sdkUserMessage) {
|
|
80
|
+
if (resolveNext) {
|
|
81
|
+
turnActive = true;
|
|
82
|
+
const r = resolveNext;
|
|
83
|
+
resolveNext = null;
|
|
84
|
+
r({ value: sdkUserMessage, done: false });
|
|
85
|
+
} else if (pendingMessages.length < 8) {
|
|
86
|
+
pendingMessages.push(sdkUserMessage);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 当前轮次完成:重置状态并触发下一轮读取 */
|
|
91
|
+
function onTurnComplete() {
|
|
92
|
+
turnActive = false;
|
|
93
|
+
const r = resolveNext;
|
|
94
|
+
resolveNext = null;
|
|
95
|
+
if (r) r({ done: true, value: null });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 启动持久化查询
|
|
99
|
+
const response = agentQuery({
|
|
100
|
+
prompt: messageChannel,
|
|
101
|
+
options: sdkOptions,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
let currentSessionId = existingState?.sessionId || null;
|
|
105
|
+
|
|
106
|
+
// 持久化初始状态
|
|
107
|
+
writeState(name, {
|
|
108
|
+
name,
|
|
109
|
+
pid: process.pid,
|
|
110
|
+
startedAt: new Date().toISOString(),
|
|
111
|
+
sessionId: currentSessionId,
|
|
112
|
+
model: sdkOptions.model,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// 后台任务:消费 SDK 输出并写入 stdout
|
|
116
|
+
const consumerPromise = (async () => {
|
|
117
|
+
try {
|
|
118
|
+
for await (const message of response) {
|
|
119
|
+
// 捕获初始化消息中的会话 ID,更新持久化状态
|
|
120
|
+
if (message.type === 'system' && message.subtype === 'init' && message.session_id) {
|
|
121
|
+
currentSessionId = message.session_id;
|
|
122
|
+
writeState(name, {
|
|
123
|
+
name,
|
|
124
|
+
pid: process.pid,
|
|
125
|
+
startedAt: new Date().toISOString(),
|
|
126
|
+
sessionId: currentSessionId,
|
|
127
|
+
model: sdkOptions.model,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 助手消息 → 区分为 text / tool_use / thinking 块写入 stdout
|
|
132
|
+
if (message.type === 'assistant' && message.message?.content) {
|
|
133
|
+
const content = message.message.content;
|
|
134
|
+
if (typeof content === 'string') {
|
|
135
|
+
writeMessage(process.stdout, { type: 'text', content });
|
|
136
|
+
} else if (Array.isArray(content)) {
|
|
137
|
+
for (const block of content) {
|
|
138
|
+
if (block.type === 'text') {
|
|
139
|
+
writeMessage(process.stdout, { type: 'text', content: block.text });
|
|
140
|
+
} else if (block.type === 'tool_use') {
|
|
141
|
+
writeMessage(process.stdout, {
|
|
142
|
+
type: 'tool_use',
|
|
143
|
+
name: block.name,
|
|
144
|
+
input: block.input,
|
|
145
|
+
id: block.id,
|
|
146
|
+
});
|
|
147
|
+
} else if (block.type === 'thinking') {
|
|
148
|
+
writeMessage(process.stdout, { type: 'thinking', content: block.thinking });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// result 消息表示当前轮次完成
|
|
155
|
+
if (message.type === 'result') {
|
|
156
|
+
writeMessage(process.stdout, { type: 'done', sessionId: currentSessionId });
|
|
157
|
+
onTurnComplete();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err?.code === 'ABORT_ERR') return; // 主动中断,非错误
|
|
162
|
+
writeMessage(process.stdout, {
|
|
163
|
+
type: 'error',
|
|
164
|
+
content: err instanceof Error ? err.message : String(err),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
// 主循环:从 stdin 读取协议消息,转发给 SDK
|
|
170
|
+
try {
|
|
171
|
+
while (true) {
|
|
172
|
+
let req;
|
|
173
|
+
try {
|
|
174
|
+
req = await readMessage(process.stdin);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (err?.message?.startsWith?.('message too large')) {
|
|
177
|
+
writeMessage(process.stdout, { type: 'error', content: 'message too large' });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
break; // EOF 或解析错误 → 关闭
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!req) break; // 正常 EOF
|
|
184
|
+
|
|
185
|
+
// 根据消息类型路由
|
|
186
|
+
if (req.type === 'query' && req.prompt) {
|
|
187
|
+
// 构建 SDK 用户消息
|
|
188
|
+
const sdkMessage = {
|
|
189
|
+
type: 'user',
|
|
190
|
+
message: {
|
|
191
|
+
role: 'user',
|
|
192
|
+
content: req.prompt,
|
|
193
|
+
},
|
|
194
|
+
session_id: currentSessionId || '',
|
|
195
|
+
uuid: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
enqueueMessage(sdkMessage);
|
|
199
|
+
} else if (req.type === 'ping') {
|
|
200
|
+
// ping/pong 心跳
|
|
201
|
+
writeMessage(process.stdout, { type: 'pong', sessionId: currentSessionId });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
// 清理:关闭通道、中断查询、等待消费完成、删除状态文件
|
|
206
|
+
channelClosed = true;
|
|
207
|
+
if (resolveNext) {
|
|
208
|
+
resolveNext({ done: true, value: null });
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await response.interrupt();
|
|
212
|
+
} catch { /* 忽略中断错误 */ }
|
|
213
|
+
await consumerPromise;
|
|
214
|
+
deleteState(name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话存储 — 磁盘上的持久化状态
|
|
3
|
+
*
|
|
4
|
+
* 模式:nx-sx 的 writeState/readState。
|
|
5
|
+
* 目录:$HOME/.nx-ce/instances/{name}.json
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/** 状态文件存储目录 */
|
|
13
|
+
const STATE_DIR = join(homedir(), '.nx-ce', 'instances');
|
|
14
|
+
|
|
15
|
+
/** 确保存储目录存在 */
|
|
16
|
+
function ensureDir() {
|
|
17
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 读取指定实例的持久化状态。
|
|
22
|
+
*
|
|
23
|
+
* @param {string} name - 实例名称
|
|
24
|
+
* @returns {object|null} 状态对象,不存在则返回 null
|
|
25
|
+
*/
|
|
26
|
+
export function readState(name) {
|
|
27
|
+
ensureDir();
|
|
28
|
+
const path = join(STATE_DIR, sanitize(name));
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 写入指定实例的持久化状态。
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name - 实例名称
|
|
40
|
+
* @param {object} state - 要持久化的状态对象
|
|
41
|
+
*/
|
|
42
|
+
export function writeState(name, state) {
|
|
43
|
+
ensureDir();
|
|
44
|
+
const path = join(STATE_DIR, sanitize(name));
|
|
45
|
+
writeFileSync(path, JSON.stringify(state, null, 2), 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 删除指定实例的持久化状态。
|
|
50
|
+
*
|
|
51
|
+
* @param {string} name - 实例名称
|
|
52
|
+
*/
|
|
53
|
+
export function deleteState(name) {
|
|
54
|
+
const path = join(STATE_DIR, sanitize(name));
|
|
55
|
+
try {
|
|
56
|
+
rmSync(path, { force: true });
|
|
57
|
+
} catch {
|
|
58
|
+
// 文件不存在则静默忽略
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 列出所有已知的实例。
|
|
64
|
+
*
|
|
65
|
+
* @returns {Array<{ name: string, state: object }>}
|
|
66
|
+
*/
|
|
67
|
+
export function listStates() {
|
|
68
|
+
ensureDir();
|
|
69
|
+
const files = readdirSync(STATE_DIR);
|
|
70
|
+
return files
|
|
71
|
+
.filter((f) => f.endsWith('.json'))
|
|
72
|
+
.map((f) => {
|
|
73
|
+
const name = f.slice(0, -5); // 去掉 .json 后缀
|
|
74
|
+
const state = readState(name);
|
|
75
|
+
return { name, state };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 将实例名称清理为安全的文件名。
|
|
81
|
+
* 移除非字母数字的字符,替换为下划线。
|
|
82
|
+
*
|
|
83
|
+
* @param {string} name - 原始实例名称
|
|
84
|
+
* @returns {string} 安全的文件名(带 .json 后缀)
|
|
85
|
+
*/
|
|
86
|
+
function sanitize(name) {
|
|
87
|
+
return `${String(name).replace(/[^a-zA-Z0-9._-]/g, '_')}.json`;
|
|
88
|
+
}
|