swarmpath-claudecode-bridge 1.0.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 +160 -0
- package/index.mjs +527 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# SwarmPath Claude Code Bridge v2.4.1
|
|
2
|
+
|
|
3
|
+
手机远程控制 Mac 本地 Claude Code — 一行命令连接,自动登录,无需手动粘贴 Token。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 第一步:配置(仅首次)
|
|
9
|
+
npx swarmpath-claudecode-bridge --setup
|
|
10
|
+
|
|
11
|
+
# 第二步:启动(任意项目目录下)
|
|
12
|
+
cd ~/my-project
|
|
13
|
+
npx swarmpath-claudecode-bridge
|
|
14
|
+
|
|
15
|
+
# 第三步:手机 SwarmPath Chat 输入配对码
|
|
16
|
+
/connect XXXXXX
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 原理
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
手机 SwarmPath Chat SwarmPath 服务器 Mac 本地
|
|
23
|
+
| | |
|
|
24
|
+
| |<-- WebSocket --------| bridge 启动 + 自动登录
|
|
25
|
+
| |--- 配对码 A3X7 ----->| 终端显示
|
|
26
|
+
|-- /connect A3X7 -------->| 配对成功 |
|
|
27
|
+
| | |
|
|
28
|
+
|-- 发送消息 ------------->|-- ws 转发 ------------>|
|
|
29
|
+
| | | claude -p "..." --resume
|
|
30
|
+
|<-- 流式回显 -------------|<-- ws 流式回传 --------|
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 安装 & 配置
|
|
34
|
+
|
|
35
|
+
### 前置条件
|
|
36
|
+
|
|
37
|
+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)(终端输入 `claude --version` 验证)
|
|
38
|
+
- Node.js 18+
|
|
39
|
+
- SwarmPath Chat 账号
|
|
40
|
+
|
|
41
|
+
### 首次配置
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx swarmpath-claudecode-bridge --setup
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
交互式输入:
|
|
48
|
+
```
|
|
49
|
+
🔧 SwarmPath Claude Code Bridge — 初始配置
|
|
50
|
+
|
|
51
|
+
SwarmPath 服务器地址 [wss://www.swarmpathchat.com]:
|
|
52
|
+
用户名: admin
|
|
53
|
+
密码: ****
|
|
54
|
+
|
|
55
|
+
✅ 配置已保存到 ~/.swarmpath-bridge.json
|
|
56
|
+
✅ 登录成功!
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
配置保存到 `~/.swarmpath-bridge.json`,后续启动自动读取,无需重复配置。
|
|
60
|
+
|
|
61
|
+
## 使用
|
|
62
|
+
|
|
63
|
+
### 启动
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# 当前目录
|
|
67
|
+
npx swarmpath-claudecode-bridge
|
|
68
|
+
|
|
69
|
+
# 指定项目目录
|
|
70
|
+
npx swarmpath-claudecode-bridge --cwd /Users/apple/my-project
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
输出:
|
|
74
|
+
```
|
|
75
|
+
🌐 SwarmPath Claude Code Bridge
|
|
76
|
+
📁 Working directory: /Users/apple/my-project
|
|
77
|
+
🔑 自动登录成功
|
|
78
|
+
|
|
79
|
+
🔗 Connecting to wss://www.swarmpathchat.com ...
|
|
80
|
+
✅ Connected to SwarmPath server
|
|
81
|
+
|
|
82
|
+
═══════════════════════════════════════════
|
|
83
|
+
配对码 (Pairing Code): F8USY4
|
|
84
|
+
有效期: 300 秒
|
|
85
|
+
═══════════════════════════════════════════
|
|
86
|
+
|
|
87
|
+
在 SwarmPath Chat 中输入: /connect F8USY4
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 手机端配对
|
|
91
|
+
|
|
92
|
+
在 SwarmPath Chat 任意对话中输入:
|
|
93
|
+
```
|
|
94
|
+
/connect F8USY4
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 对话
|
|
98
|
+
|
|
99
|
+
在 Bridge session 中直接发消息:
|
|
100
|
+
- `帮我看看当前目录有什么文件`
|
|
101
|
+
- `帮我写一个 hello.py`
|
|
102
|
+
- `运行测试`
|
|
103
|
+
- `帮我把这个项目的架构图绘制出来`
|
|
104
|
+
|
|
105
|
+
### 切换模型
|
|
106
|
+
|
|
107
|
+
点击 header 模型名(Sonnet/Opus/Haiku)切换 Claude Code 使用的模型。
|
|
108
|
+
|
|
109
|
+
### 切换工作目录
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
/cd /Users/apple/another-project
|
|
113
|
+
/cd ..
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 重新连接
|
|
117
|
+
|
|
118
|
+
bridge 重启后,在同一个 Bridge session 中输入新配对码即可重连(不创建新 session,历史保留):
|
|
119
|
+
```
|
|
120
|
+
/connect XXXXXX
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## 参数
|
|
124
|
+
|
|
125
|
+
| 参数 | 说明 |
|
|
126
|
+
|------|------|
|
|
127
|
+
| `--setup` | 交互式首次配置 |
|
|
128
|
+
| `--cwd <path>` | 指定工作目录(默认当前目录) |
|
|
129
|
+
| `--server <url>` | 覆盖服务器地址 |
|
|
130
|
+
| `--token <jwt>` | 手动指定 token(跳过自动登录) |
|
|
131
|
+
|
|
132
|
+
环境变量:
|
|
133
|
+
```bash
|
|
134
|
+
export SWARMPATH_SERVER=wss://www.swarmpathchat.com
|
|
135
|
+
export SWARMPATH_TOKEN=eyJ...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## 特性
|
|
139
|
+
|
|
140
|
+
| 特性 | 说明 |
|
|
141
|
+
|------|------|
|
|
142
|
+
| **自动登录** | 首次 `--setup` 后,后续启动自动用存储的凭据登录 |
|
|
143
|
+
| **Token 自动续期** | 每 12 分钟自动刷新,无需手动干预 |
|
|
144
|
+
| **断线重连** | WebSocket 断开后自动重连(指数退避 5s → 60s) |
|
|
145
|
+
| **Token 过期自动恢复** | 4001 断开时自动重新登录并重连 |
|
|
146
|
+
| **长对话上下文** | `--resume` 复用 Claude session,多轮对话保持上下文 |
|
|
147
|
+
| **远程文件浏览** | 资源管理器远程浏览 Mac 文件(只读) |
|
|
148
|
+
| **`/cd` 切目录** | 动态切换工作目录,无需重启 |
|
|
149
|
+
|
|
150
|
+
## 安全
|
|
151
|
+
|
|
152
|
+
- 凭据存储在本地 `~/.swarmpath-bridge.json`(权限 600 建议)
|
|
153
|
+
- 配对码 6 位加密随机,5 分钟有效,一次性
|
|
154
|
+
- 所有通信通过 WSS 加密
|
|
155
|
+
- 服务器仅转发,不存储文件内容
|
|
156
|
+
- 远程资源管理器只读
|
|
157
|
+
|
|
158
|
+
## 退出
|
|
159
|
+
|
|
160
|
+
终端按 `Ctrl+C` 优雅退出。
|
package/index.mjs
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SwarmPath Claude Code Bridge
|
|
4
|
+
* Connect local Claude Code to SwarmPath Chat — control your Mac from your phone.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx swarmpath-claudecode-bridge --setup # First-time setup
|
|
8
|
+
* npx swarmpath-claudecode-bridge # Start (auto-login)
|
|
9
|
+
* npx swarmpath-claudecode-bridge --cwd /path/to/project # Specify working directory
|
|
10
|
+
* npx swarmpath-claudecode-bridge --server wss://... --token <jwt> # Manual token mode
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
import { resolve, join, extname } from 'path';
|
|
15
|
+
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import { createInterface } from 'readline';
|
|
18
|
+
import WebSocket from 'ws';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Config file
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const CONFIG_PATH = join(homedir(), '.swarmpath-bridge.json');
|
|
24
|
+
|
|
25
|
+
function loadConfig() {
|
|
26
|
+
try {
|
|
27
|
+
if (existsSync(CONFIG_PATH)) return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
28
|
+
} catch {}
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function saveConfig(cfg) {
|
|
33
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Interactive setup
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
async function runSetup() {
|
|
40
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
41
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
42
|
+
|
|
43
|
+
console.log('\n🔧 SwarmPath Claude Code Bridge — 初始配置\n');
|
|
44
|
+
|
|
45
|
+
const cfg = loadConfig();
|
|
46
|
+
const server = await ask(`SwarmPath 服务器地址 [${cfg.server || 'wss://www.swarmpathchat.com'}]: `);
|
|
47
|
+
const username = await ask(`用户名 [${cfg.username || ''}]: `);
|
|
48
|
+
const password = await ask('密码: ');
|
|
49
|
+
|
|
50
|
+
cfg.server = server.trim() || cfg.server || 'wss://www.swarmpathchat.com';
|
|
51
|
+
cfg.username = username.trim() || cfg.username || '';
|
|
52
|
+
cfg.password = password || cfg.password || '';
|
|
53
|
+
|
|
54
|
+
saveConfig(cfg);
|
|
55
|
+
console.log(`\n✅ 配置已保存到 ${CONFIG_PATH}`);
|
|
56
|
+
|
|
57
|
+
// Test login
|
|
58
|
+
try {
|
|
59
|
+
const tokens = await login(cfg.server, cfg.username, cfg.password);
|
|
60
|
+
cfg.accessToken = tokens.accessToken;
|
|
61
|
+
cfg.refreshToken = tokens.refreshToken;
|
|
62
|
+
saveConfig(cfg);
|
|
63
|
+
console.log('✅ 登录成功!\n');
|
|
64
|
+
console.log('现在可以运行:');
|
|
65
|
+
console.log(' npx swarmpath-claudecode-bridge');
|
|
66
|
+
console.log(' npx swarmpath-claudecode-bridge --cwd /path/to/project\n');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`❌ 登录失败: ${err.message}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
rl.close();
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Auth: login & refresh
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
async function login(serverWs, username, password) {
|
|
79
|
+
const httpUrl = serverWs.replace('wss://', 'https://').replace('ws://', 'http://');
|
|
80
|
+
const res = await fetch(`${httpUrl}/auth/login`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ username, password }),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const body = await res.json().catch(() => ({}));
|
|
87
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
88
|
+
}
|
|
89
|
+
return res.json();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function refreshToken(serverWs, rToken) {
|
|
93
|
+
const httpUrl = serverWs.replace('wss://', 'https://').replace('ws://', 'http://');
|
|
94
|
+
const res = await fetch(`${httpUrl}/auth/refresh`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ refreshToken: rToken }),
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) throw new Error('Refresh failed');
|
|
100
|
+
return res.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function ensureToken(cfg) {
|
|
104
|
+
// Try existing access token
|
|
105
|
+
if (cfg.accessToken) return cfg.accessToken;
|
|
106
|
+
|
|
107
|
+
// Try refresh
|
|
108
|
+
if (cfg.refreshToken) {
|
|
109
|
+
try {
|
|
110
|
+
const tokens = await refreshToken(cfg.server, cfg.refreshToken);
|
|
111
|
+
cfg.accessToken = tokens.accessToken;
|
|
112
|
+
if (tokens.refreshToken) cfg.refreshToken = tokens.refreshToken;
|
|
113
|
+
saveConfig(cfg);
|
|
114
|
+
return cfg.accessToken;
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Re-login with stored credentials
|
|
119
|
+
if (cfg.username && cfg.password) {
|
|
120
|
+
const tokens = await login(cfg.server, cfg.username, cfg.password);
|
|
121
|
+
cfg.accessToken = tokens.accessToken;
|
|
122
|
+
cfg.refreshToken = tokens.refreshToken;
|
|
123
|
+
saveConfig(cfg);
|
|
124
|
+
return cfg.accessToken;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw new Error('No valid credentials. Run: npx swarmpath-claudecode-bridge --setup');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Parse CLI args
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
const cliArgs = process.argv.slice(2);
|
|
134
|
+
function getArg(name) {
|
|
135
|
+
const idx = cliArgs.indexOf(`--${name}`);
|
|
136
|
+
return idx >= 0 && idx + 1 < cliArgs.length ? cliArgs[idx + 1] : null;
|
|
137
|
+
}
|
|
138
|
+
const hasFlag = (name) => cliArgs.includes(`--${name}`);
|
|
139
|
+
|
|
140
|
+
// Handle --setup
|
|
141
|
+
if (hasFlag('setup')) {
|
|
142
|
+
await runSetup();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolve config
|
|
146
|
+
const cfg = loadConfig();
|
|
147
|
+
const SERVER = getArg('server') || cfg.server || process.env.SWARMPATH_SERVER;
|
|
148
|
+
let TOKEN = getArg('token') || process.env.SWARMPATH_TOKEN;
|
|
149
|
+
let CWD = getArg('cwd') || process.cwd();
|
|
150
|
+
|
|
151
|
+
if (!SERVER) {
|
|
152
|
+
console.error('❌ 未配置服务器。请先运行: npx swarmpath-claudecode-bridge --setup');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Auto-login if no token provided
|
|
157
|
+
if (!TOKEN) {
|
|
158
|
+
try {
|
|
159
|
+
TOKEN = await ensureToken(cfg);
|
|
160
|
+
console.log('🔑 自动登录成功');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`❌ ${err.message}`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// State
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
let ws = null;
|
|
171
|
+
let reconnectTimer = null;
|
|
172
|
+
let heartbeatTimer = null;
|
|
173
|
+
let tokenRefreshTimer = null;
|
|
174
|
+
let activeChild = null;
|
|
175
|
+
let activeRequestId = null;
|
|
176
|
+
let claudeSessionId = null;
|
|
177
|
+
let shuttingDown = false;
|
|
178
|
+
|
|
179
|
+
const HEARTBEAT_MS = 30_000;
|
|
180
|
+
const RECONNECT_MS = 5_000;
|
|
181
|
+
const MAX_RECONNECT_MS = 60_000;
|
|
182
|
+
const TOKEN_REFRESH_MS = 12 * 60 * 1000; // Refresh token every 12 min (before 15-min expiry)
|
|
183
|
+
let reconnectDelay = RECONNECT_MS;
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Token auto-refresh
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
function startTokenRefresh() {
|
|
189
|
+
clearInterval(tokenRefreshTimer);
|
|
190
|
+
tokenRefreshTimer = setInterval(async () => {
|
|
191
|
+
try {
|
|
192
|
+
TOKEN = await ensureToken(cfg);
|
|
193
|
+
console.log('🔑 Token 已自动续期');
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.error('⚠️ Token 续期失败:', err.message);
|
|
196
|
+
}
|
|
197
|
+
}, TOKEN_REFRESH_MS);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// WebSocket connection
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
function connect() {
|
|
204
|
+
const cwdParam = encodeURIComponent(CWD);
|
|
205
|
+
const url = `${SERVER}/ws/bridge?token=${TOKEN}&cwd=${cwdParam}`;
|
|
206
|
+
|
|
207
|
+
console.log(`\n🔗 Connecting to ${SERVER} ...`);
|
|
208
|
+
ws = new WebSocket(url);
|
|
209
|
+
|
|
210
|
+
ws.on('open', () => {
|
|
211
|
+
console.log('✅ Connected to SwarmPath server');
|
|
212
|
+
reconnectDelay = RECONNECT_MS;
|
|
213
|
+
|
|
214
|
+
clearInterval(heartbeatTimer);
|
|
215
|
+
heartbeatTimer = setInterval(() => {
|
|
216
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
217
|
+
ws.send(JSON.stringify({ type: 'heartbeat' }));
|
|
218
|
+
}
|
|
219
|
+
}, HEARTBEAT_MS);
|
|
220
|
+
|
|
221
|
+
ws.send(JSON.stringify({ type: 'cwd', cwd: CWD }));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
ws.on('message', (raw) => {
|
|
225
|
+
try {
|
|
226
|
+
const msg = JSON.parse(raw.toString());
|
|
227
|
+
handleMessage(msg);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error('Failed to parse message:', e.message);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
ws.on('close', async (code, reason) => {
|
|
234
|
+
const reasonStr = reason?.toString() || 'none';
|
|
235
|
+
console.log(`🔌 Disconnected (code=${code}, reason=${reasonStr})`);
|
|
236
|
+
clearInterval(heartbeatTimer);
|
|
237
|
+
|
|
238
|
+
// If token expired, auto-refresh and reconnect
|
|
239
|
+
if (code === 4001 && !shuttingDown) {
|
|
240
|
+
console.log('🔑 Token 过期,尝试自动续期...');
|
|
241
|
+
try {
|
|
242
|
+
TOKEN = await ensureToken(cfg);
|
|
243
|
+
console.log('🔑 续期成功,重新连接...');
|
|
244
|
+
reconnectDelay = RECONNECT_MS;
|
|
245
|
+
setTimeout(() => connect(), 1000);
|
|
246
|
+
return;
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error('❌ 续期失败:', err.message);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!shuttingDown) scheduleReconnect();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
ws.on('error', (err) => {
|
|
256
|
+
console.error('WebSocket error:', err.message);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function scheduleReconnect() {
|
|
261
|
+
if (reconnectTimer) return;
|
|
262
|
+
console.log(`⏳ Reconnecting in ${reconnectDelay / 1000}s ...`);
|
|
263
|
+
reconnectTimer = setTimeout(() => {
|
|
264
|
+
reconnectTimer = null;
|
|
265
|
+
reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_MS);
|
|
266
|
+
connect();
|
|
267
|
+
}, reconnectDelay);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Message handling
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
function handleMessage(msg) {
|
|
274
|
+
switch (msg.type) {
|
|
275
|
+
case 'paired_code':
|
|
276
|
+
console.log('');
|
|
277
|
+
console.log('═══════════════════════════════════════════');
|
|
278
|
+
console.log(` 配对码 (Pairing Code): ${msg.code}`);
|
|
279
|
+
console.log(` 有效期: ${msg.expiresIn || 300} 秒`);
|
|
280
|
+
console.log('═══════════════════════════════════════════');
|
|
281
|
+
console.log('');
|
|
282
|
+
console.log('在 SwarmPath Chat 中输入: /connect ' + msg.code);
|
|
283
|
+
console.log('');
|
|
284
|
+
break;
|
|
285
|
+
|
|
286
|
+
case 'paired':
|
|
287
|
+
console.log(`🤝 配对成功! (bridgeId: ${msg.bridgeId})`);
|
|
288
|
+
console.log(`📁 工作目录: ${CWD}`);
|
|
289
|
+
console.log('等待指令...\n');
|
|
290
|
+
break;
|
|
291
|
+
|
|
292
|
+
case 'prompt':
|
|
293
|
+
console.log(`📨 收到指令 [${msg.requestId}]${msg.model ? ' (' + msg.model + ')' : ''}: ${msg.prompt.slice(0, 80)}${msg.prompt.length > 80 ? '...' : ''}`);
|
|
294
|
+
executePrompt(msg.requestId, msg.prompt, msg.model);
|
|
295
|
+
break;
|
|
296
|
+
|
|
297
|
+
case 'abort':
|
|
298
|
+
console.log(`⛔ 收到中止指令 [${msg.requestId}]`);
|
|
299
|
+
if (activeChild && activeRequestId === msg.requestId) {
|
|
300
|
+
activeChild.kill('SIGTERM');
|
|
301
|
+
setTimeout(() => { if (activeChild && !activeChild.killed) activeChild.kill('SIGKILL'); }, 3000);
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
|
|
305
|
+
case 'file_request':
|
|
306
|
+
handleFileRequest(msg);
|
|
307
|
+
break;
|
|
308
|
+
|
|
309
|
+
case 'error':
|
|
310
|
+
console.error('Server error:', msg.message);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Execute Claude CLI
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
function executePrompt(requestId, prompt, model) {
|
|
319
|
+
if (activeChild && activeRequestId) {
|
|
320
|
+
const prevReqId = activeRequestId;
|
|
321
|
+
activeChild.kill('SIGTERM');
|
|
322
|
+
wsSend({ type: 'stream_error', requestId: prevReqId, error: 'Superseded by new request' });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
activeRequestId = requestId;
|
|
326
|
+
|
|
327
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
|
|
328
|
+
if (claudeSessionId) args.push('--resume', claudeSessionId);
|
|
329
|
+
if (model) args.push('--model', model);
|
|
330
|
+
|
|
331
|
+
const child = spawn('claude', args, {
|
|
332
|
+
cwd: CWD,
|
|
333
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
334
|
+
env: { ...process.env },
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
activeChild = child;
|
|
338
|
+
wsSend({ type: 'stream_start', requestId });
|
|
339
|
+
|
|
340
|
+
let buffer = '';
|
|
341
|
+
let hasSentDelta = false;
|
|
342
|
+
|
|
343
|
+
child.stdout.on('data', (chunk) => {
|
|
344
|
+
buffer += chunk.toString();
|
|
345
|
+
const lines = buffer.split('\n');
|
|
346
|
+
buffer = lines.pop() || '';
|
|
347
|
+
|
|
348
|
+
for (const line of lines) {
|
|
349
|
+
if (!line.trim()) continue;
|
|
350
|
+
try {
|
|
351
|
+
const event = JSON.parse(line);
|
|
352
|
+
if (event.session_id && !claudeSessionId) {
|
|
353
|
+
claudeSessionId = event.session_id;
|
|
354
|
+
console.log(` 📎 Session ID: ${claudeSessionId}`);
|
|
355
|
+
}
|
|
356
|
+
hasSentDelta = processClaudeEvent(requestId, event, hasSentDelta) || hasSentDelta;
|
|
357
|
+
} catch {
|
|
358
|
+
if (line.trim()) {
|
|
359
|
+
wsSend({ type: 'stream_delta', requestId, content: line + '\n' });
|
|
360
|
+
hasSentDelta = true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const MAX_STDERR = 64 * 1024;
|
|
367
|
+
let stderrBuf = '';
|
|
368
|
+
child.stderr.on('data', (chunk) => {
|
|
369
|
+
if (stderrBuf.length < MAX_STDERR) {
|
|
370
|
+
stderrBuf += chunk.toString();
|
|
371
|
+
if (stderrBuf.length > MAX_STDERR) stderrBuf = stderrBuf.slice(0, MAX_STDERR);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
child.on('close', (code) => {
|
|
376
|
+
if (buffer.trim()) {
|
|
377
|
+
try {
|
|
378
|
+
const event = JSON.parse(buffer);
|
|
379
|
+
hasSentDelta = processClaudeEvent(requestId, event, hasSentDelta) || hasSentDelta;
|
|
380
|
+
} catch {
|
|
381
|
+
if (buffer.trim()) wsSend({ type: 'stream_delta', requestId, content: buffer });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (activeRequestId === requestId) {
|
|
385
|
+
if (code === 0) wsSend({ type: 'stream_done', requestId });
|
|
386
|
+
else wsSend({ type: 'stream_error', requestId, error: stderrBuf.trim() || `Claude exited with code ${code}` });
|
|
387
|
+
activeChild = null;
|
|
388
|
+
activeRequestId = null;
|
|
389
|
+
}
|
|
390
|
+
console.log(` ✓ 完成 [${requestId}] (exit=${code})`);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
child.on('error', (err) => {
|
|
394
|
+
console.error(` ✗ 失败 [${requestId}]:`, err.message);
|
|
395
|
+
wsSend({ type: 'stream_error', requestId, error: err.message });
|
|
396
|
+
if (activeRequestId === requestId) { activeChild = null; activeRequestId = null; }
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function processClaudeEvent(requestId, event, hasSentDelta) {
|
|
401
|
+
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
402
|
+
wsSend({ type: 'stream_delta', requestId, content: event.delta.text });
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
406
|
+
let sent = false;
|
|
407
|
+
for (const block of event.message.content) {
|
|
408
|
+
if (block.type === 'text' && block.text) {
|
|
409
|
+
if (hasSentDelta) wsSend({ type: 'stream_delta', requestId, content: '\n\n' });
|
|
410
|
+
wsSend({ type: 'stream_delta', requestId, content: block.text });
|
|
411
|
+
sent = true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return sent || hasSentDelta;
|
|
415
|
+
}
|
|
416
|
+
if (event.type === 'result' && !hasSentDelta) {
|
|
417
|
+
const text = typeof event.result === 'string' ? event.result : event.result?.text || '';
|
|
418
|
+
if (text) { wsSend({ type: 'stream_delta', requestId, content: text }); return true; }
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// File operations
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.next', '__pycache__', '.cache', 'dist', '.DS_Store']);
|
|
427
|
+
const MIME_MAP = {
|
|
428
|
+
'.js': 'text/javascript', '.mjs': 'text/javascript', '.ts': 'text/plain', '.tsx': 'text/plain',
|
|
429
|
+
'.json': 'application/json', '.md': 'text/markdown', '.txt': 'text/plain', '.html': 'text/html',
|
|
430
|
+
'.css': 'text/css', '.py': 'text/plain', '.sh': 'text/plain', '.yaml': 'text/yaml', '.yml': 'text/yaml',
|
|
431
|
+
'.xml': 'text/xml', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
432
|
+
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf',
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
function handleFileRequest(msg) {
|
|
436
|
+
const { requestId, action } = msg;
|
|
437
|
+
try {
|
|
438
|
+
if (action === 'cd') {
|
|
439
|
+
const target = msg.path || '..';
|
|
440
|
+
const newCwd = resolve(CWD, target);
|
|
441
|
+
const st = statSync(newCwd);
|
|
442
|
+
if (!st.isDirectory()) throw new Error('Not a directory');
|
|
443
|
+
CWD = newCwd;
|
|
444
|
+
wsSend({ type: 'cwd', cwd: CWD });
|
|
445
|
+
console.log(` 📂 切换目录: ${CWD}`);
|
|
446
|
+
wsSend({ type: 'file_response', requestId, data: { cwd: CWD } });
|
|
447
|
+
} else if (action === 'tree') {
|
|
448
|
+
const tree = buildTree(CWD, '', msg.depth || 3, !!msg.showHidden);
|
|
449
|
+
wsSend({ type: 'file_response', requestId, data: { cwd: CWD, tree } });
|
|
450
|
+
} else if (action === 'read') {
|
|
451
|
+
const relPath = msg.path;
|
|
452
|
+
if (!relPath) throw new Error('path required');
|
|
453
|
+
const fullPath = resolve(CWD, relPath);
|
|
454
|
+
if (!fullPath.startsWith(resolve(CWD) + '/') && fullPath !== resolve(CWD)) throw new Error('Access denied');
|
|
455
|
+
const ext = extname(fullPath).toLowerCase();
|
|
456
|
+
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
|
457
|
+
const isText = mime.startsWith('text/') || mime === 'application/json' || mime === 'text/markdown';
|
|
458
|
+
if (isText) {
|
|
459
|
+
wsSend({ type: 'file_response', requestId, data: { content: readFileSync(fullPath, 'utf-8'), mimeType: mime, encoding: 'utf8' } });
|
|
460
|
+
} else {
|
|
461
|
+
const buf = readFileSync(fullPath);
|
|
462
|
+
if (buf.length > 2 * 1024 * 1024) throw new Error('File too large');
|
|
463
|
+
wsSend({ type: 'file_response', requestId, data: { content: buf.toString('base64'), mimeType: mime, encoding: 'base64' } });
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
throw new Error(`Unknown action: ${action}`);
|
|
467
|
+
}
|
|
468
|
+
} catch (err) {
|
|
469
|
+
wsSend({ type: 'file_response', requestId, error: err.message || String(err) });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function buildTree(dir, relPrefix, maxDepth, showHidden, depth = 0) {
|
|
474
|
+
if (depth >= maxDepth) return [];
|
|
475
|
+
const entries = [];
|
|
476
|
+
try {
|
|
477
|
+
for (const item of readdirSync(dir, { withFileTypes: true })) {
|
|
478
|
+
if (!showHidden && item.name.startsWith('.')) continue;
|
|
479
|
+
if (SKIP_DIRS.has(item.name)) continue;
|
|
480
|
+
const relPath = relPrefix ? relPrefix + '/' + item.name : item.name;
|
|
481
|
+
if (item.isDirectory()) {
|
|
482
|
+
entries.push({ name: item.name, path: relPath, isDir: true, children: buildTree(join(dir, item.name), relPath, maxDepth, showHidden, depth + 1) });
|
|
483
|
+
} else {
|
|
484
|
+
try { entries.push({ name: item.name, path: relPath, isDir: false, size: statSync(join(dir, item.name)).size }); }
|
|
485
|
+
catch { entries.push({ name: item.name, path: relPath, isDir: false, size: 0 }); }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} catch {}
|
|
489
|
+
entries.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1));
|
|
490
|
+
return entries;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Helpers
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
function wsSend(data) {
|
|
497
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// Graceful shutdown
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
function shutdown() {
|
|
504
|
+
if (shuttingDown) return;
|
|
505
|
+
shuttingDown = true;
|
|
506
|
+
console.log('\n🛑 Shutting down...');
|
|
507
|
+
clearInterval(heartbeatTimer);
|
|
508
|
+
clearInterval(tokenRefreshTimer);
|
|
509
|
+
clearTimeout(reconnectTimer);
|
|
510
|
+
if (activeChild) {
|
|
511
|
+
activeChild.kill('SIGTERM');
|
|
512
|
+
setTimeout(() => { if (activeChild && !activeChild.killed) activeChild.kill('SIGKILL'); }, 2000);
|
|
513
|
+
}
|
|
514
|
+
if (ws) ws.close(1000, 'Shutdown');
|
|
515
|
+
setTimeout(() => process.exit(0), 3000);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
process.on('SIGINT', shutdown);
|
|
519
|
+
process.on('SIGTERM', shutdown);
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Start
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
console.log('🌐 SwarmPath Claude Code Bridge');
|
|
525
|
+
console.log(`📁 Working directory: ${CWD}`);
|
|
526
|
+
startTokenRefresh();
|
|
527
|
+
connect();
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "swarmpath-claudecode-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Connect local Claude Code to SwarmPath Chat — control your Mac from your phone",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"swarmpath-claudecode-bridge": "./index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"claude-code",
|
|
11
|
+
"swarmpath",
|
|
12
|
+
"bridge",
|
|
13
|
+
"remote-control",
|
|
14
|
+
"ai-agent"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/SwarmPathAI/swarmpath-agent-chat"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ws": "^8.19.0"
|
|
23
|
+
}
|
|
24
|
+
}
|