claude-remote 0.1.0 → 0.1.1
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 +80 -50
- package/package.json +1 -1
- package/server.js +119 -156
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# Claude
|
|
1
|
+
# Claude Remote
|
|
2
2
|
|
|
3
3
|
> 在手机上远程操控 Claude Code —— 随时随地写代码、审批权限、切换模型。
|
|
4
4
|
|
|
5
5
|
## 这是什么?
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
一个轻量级的远程控制桥接器,让你可以通过 Android App 连接到电脑上运行的 Claude Code,实现:
|
|
8
8
|
|
|
9
9
|
- 💬 远程对话 —— 发消息、看回复、完整的 Markdown 渲染
|
|
10
10
|
- 🔧 工具调用可视化 —— 实时查看 Claude 正在读哪个文件、执行什么命令
|
|
@@ -12,50 +12,79 @@
|
|
|
12
12
|
- 🔄 模式切换 —— Default / Plan / Accept Edits / Bypass
|
|
13
13
|
- 📱 斜杠命令 —— /model /compact /clear /cost /help
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## 快速开始
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
└─────────────┘ └──────────────┘ └────────────┘
|
|
22
|
-
│
|
|
23
|
-
│ 读取 JSONL transcript
|
|
24
|
-
▼
|
|
25
|
-
~/.claude/projects/
|
|
17
|
+
### 1. 安装
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g claude-remote
|
|
26
21
|
```
|
|
27
22
|
|
|
28
|
-
|
|
29
|
-
- 通过 `node-pty` 启动并管理 Claude Code CLI 进程
|
|
30
|
-
- 监听 `~/.claude/projects/` 下的 JSONL transcript 文件,实时解析事件
|
|
31
|
-
- 通过 WebSocket 将事件广播给所有连接的客户端
|
|
32
|
-
- 转发客户端输入到 Claude PTY
|
|
23
|
+
### 2. 启动
|
|
33
24
|
|
|
34
|
-
|
|
25
|
+
在任意项目目录下运行:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
claude-remote
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
会在当前目录启动 Claude Code 并开启远程控制服务(默认端口 3100)。
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
指定端口:
|
|
37
34
|
|
|
38
35
|
```bash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
PORT=8080 claude-remote
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 3. App 连接
|
|
40
|
+
|
|
41
|
+
安装 Android App 后,在设置页输入服务器地址连接。根据你的网络环境,有以下三种方式:
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
node /path/to/claudecode_api_RemoteControl/server.js
|
|
43
|
+
#### 方式一:局域网直连
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
手机和电脑在同一 Wi-Fi 下,直接输入电脑内网 IP:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
ws://<电脑IP>:3100
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
> 适合在家或办公室使用,最简单、延迟最低。
|
|
52
|
+
|
|
53
|
+
#### 方式二:Tailscale 组网
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
通过 [Tailscale](https://tailscale.com) 虚拟局域网,手机和电脑不在同一网络也能连接。
|
|
53
56
|
|
|
57
|
+
1. 电脑和手机都安装 Tailscale 并登录同一账号
|
|
58
|
+
2. 在 Tailscale 管理面板查看电脑的 Tailscale IP(通常是 `100.x.x.x`)
|
|
59
|
+
3. App 中输入:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
ws://<Tailscale IP>:3100
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> 适合跨网络使用,无需公网暴露,安全可靠。
|
|
66
|
+
|
|
67
|
+
#### 方式三:Cloudflare Tunnel 公网访问
|
|
68
|
+
|
|
69
|
+
通过 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 将本地服务暴露到公网。
|
|
70
|
+
|
|
71
|
+
1. 安装 `cloudflared` 并登录
|
|
72
|
+
2. 启动隧道:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cloudflared tunnel --url http://localhost:3100
|
|
54
76
|
```
|
|
55
|
-
|
|
77
|
+
|
|
78
|
+
3. 获得一个 `https://xxx.trycloudflare.com` 的地址
|
|
79
|
+
4. App 中输入(注意用 `wss://`):
|
|
80
|
+
|
|
56
81
|
```
|
|
82
|
+
wss://xxx.trycloudflare.com
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> 适合在外网或移动网络下远程控制,支持 HTTPS/WSS 加密。
|
|
57
86
|
|
|
58
|
-
###
|
|
87
|
+
### 4. 编译 Android App
|
|
59
88
|
|
|
60
89
|
```bash
|
|
61
90
|
cd app
|
|
@@ -64,26 +93,25 @@ npx tauri android init
|
|
|
64
93
|
npx tauri android dev
|
|
65
94
|
```
|
|
66
95
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
## 项目结构
|
|
96
|
+
## 架构
|
|
70
97
|
|
|
71
98
|
```
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
│
|
|
75
|
-
|
|
76
|
-
│
|
|
77
|
-
│
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
│ └── src-tauri/
|
|
81
|
-
│ ├── src/lib.rs # Rust 入口(最小化)
|
|
82
|
-
│ └── tauri.conf.json
|
|
83
|
-
├── hooks/ # Claude Code permission hooks
|
|
84
|
-
└── package.json
|
|
99
|
+
┌─────────────┐ WebSocket ┌──────────────┐ PTY ┌────────────┐
|
|
100
|
+
│ Android App│ ◄──────────────► │ server.js │ ◄──────────► │ Claude Code│
|
|
101
|
+
│ │ ws://ip:3100 │ (Bridge) │ node-pty │ (CLI) │
|
|
102
|
+
└─────────────┘ └──────────────┘ └────────────┘
|
|
103
|
+
│
|
|
104
|
+
│ 读取 JSONL transcript
|
|
105
|
+
▼
|
|
106
|
+
~/.claude/projects/
|
|
85
107
|
```
|
|
86
108
|
|
|
109
|
+
**server.js** 是核心桥接层:
|
|
110
|
+
- 通过 `node-pty` 启动并管理 Claude Code CLI 进程
|
|
111
|
+
- 监听 `~/.claude/projects/` 下的 JSONL transcript 文件,实时解析事件
|
|
112
|
+
- 通过 WebSocket 将事件广播给所有连接的客户端
|
|
113
|
+
- 转发客户端输入到 Claude PTY
|
|
114
|
+
|
|
87
115
|
## 功能特性
|
|
88
116
|
|
|
89
117
|
| 功能 | 说明 |
|
|
@@ -96,7 +124,8 @@ App 启动后输入 server 地址连接即可。
|
|
|
96
124
|
| 斜杠命令菜单 | 输入 `/` 弹出命令面板 |
|
|
97
125
|
| 模型切换 | 底部面板选择,toast 即时反馈 |
|
|
98
126
|
| 断线重连 | 自动重连 + 事件回放,不丢消息 |
|
|
99
|
-
|
|
|
127
|
+
| 图片上传 | 手机拍照/选图发送到 Claude Code |
|
|
128
|
+
| 代码 Diff | GitHub 风格行内高亮 |
|
|
100
129
|
| 安卓适配 | 安全区、虚拟键盘、大触摸目标 |
|
|
101
130
|
|
|
102
131
|
## 依赖
|
|
@@ -104,11 +133,11 @@ App 启动后输入 server 地址连接即可。
|
|
|
104
133
|
- **Node.js** >= 18
|
|
105
134
|
- **node-pty** —— PTY 终端模拟
|
|
106
135
|
- **ws** —— WebSocket 服务
|
|
107
|
-
- **Tauri 2.0** —— Android App
|
|
136
|
+
- **Tauri 2.0** —— Android App(需要 Rust 工具链)
|
|
108
137
|
|
|
109
138
|
## TODO
|
|
110
139
|
|
|
111
|
-
> 本项目看到 Claude Code 官方远程控制,可惜不支持
|
|
140
|
+
> 本项目看到 Claude Code 官方远程控制,可惜不支持 API 调用的模式,于是空闲时间摸鱼用 vibing code 写的小东西,目前处于快速迭代阶段。
|
|
112
141
|
|
|
113
142
|
- [x] 提问与计划远程弹窗(AskUserQuestion / ExitPlanMode 远程交互)
|
|
114
143
|
- [x] 自动审批命令模式
|
|
@@ -118,5 +147,6 @@ App 启动后输入 server 地址连接即可。
|
|
|
118
147
|
- [x] 缓存机制 —— 避免每次 App 重连全量回放
|
|
119
148
|
- [x] App 支持图片上传
|
|
120
149
|
- [x] /命令指令及样式美化
|
|
150
|
+
- [x] npm 包化 —— `npm install -g claude-remote` 全局安装,`claude-remote` 一键启动
|
|
151
|
+
- [x] AI 截图显示 —— 工具返回的图片(如 Playwright 截图)全宽渲染,点击全屏查看
|
|
121
152
|
- [ ] Tool Use 状态渲染(进行中 / 成功 / 失败)
|
|
122
|
-
- [ ] npm 包化 —— 改造为 npm install 全局安装,支持 npx 一键启动
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -16,11 +16,12 @@ const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
|
|
|
16
16
|
let claudeProc = null;
|
|
17
17
|
let transcriptPath = null;
|
|
18
18
|
let currentSessionId = null;
|
|
19
|
-
let transcriptOffset = 0;
|
|
20
|
-
let eventBuffer = [];
|
|
21
|
-
let eventSeq = 0;
|
|
22
|
-
const EVENT_BUFFER_MAX = 5000;
|
|
23
|
-
let
|
|
19
|
+
let transcriptOffset = 0;
|
|
20
|
+
let eventBuffer = [];
|
|
21
|
+
let eventSeq = 0;
|
|
22
|
+
const EVENT_BUFFER_MAX = 5000;
|
|
23
|
+
let nextWsId = 0;
|
|
24
|
+
let tailTimer = null;
|
|
24
25
|
let discoveryTimer = null;
|
|
25
26
|
let switchWatcher = null;
|
|
26
27
|
let expectingSwitch = false;
|
|
@@ -36,7 +37,6 @@ const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
|
|
36
37
|
let approvalSeq = 0;
|
|
37
38
|
const pendingApprovals = new Map(); // id → { res, timer }
|
|
38
39
|
const pendingImageUploads = new Map();
|
|
39
|
-
let currentMode = 'default';
|
|
40
40
|
let approvalMode = 'default'; // 'default' | 'partial' | 'all'
|
|
41
41
|
const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
|
|
42
42
|
const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
|
|
@@ -44,10 +44,28 @@ const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
|
|
|
44
44
|
// --- Logging → file only (never pollute the terminal) ---
|
|
45
45
|
const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
|
|
46
46
|
fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
|
|
47
|
-
function log(msg) {
|
|
48
|
-
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
49
|
-
fs.appendFileSync(LOG_FILE, line);
|
|
50
|
-
}
|
|
47
|
+
function log(msg) {
|
|
48
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
49
|
+
fs.appendFileSync(LOG_FILE, line);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function wsLabel(ws) {
|
|
53
|
+
const clientId = ws && ws._clientInstanceId ? ` client=${ws._clientInstanceId}` : '';
|
|
54
|
+
return `ws#${ws && ws._bridgeId ? ws._bridgeId : '?'}${clientId}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sendWs(ws, msg, context = '') {
|
|
58
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
59
|
+
ws.send(JSON.stringify(msg));
|
|
60
|
+
if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'replay_done') {
|
|
61
|
+
const extra = [];
|
|
62
|
+
if (msg.sessionId !== undefined) extra.push(`session=${msg.sessionId ?? 'null'}`);
|
|
63
|
+
if (msg.lastSeq !== undefined) extra.push(`lastSeq=${msg.lastSeq}`);
|
|
64
|
+
if (msg.resumed !== undefined) extra.push(`resumed=${msg.resumed}`);
|
|
65
|
+
log(`Send ${msg.type}${context ? ` (${context})` : ''} -> ${wsLabel(ws)}${extra.length ? ` ${extra.join(' ')}` : ''}`);
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
51
69
|
|
|
52
70
|
// ============================================================
|
|
53
71
|
// 1. Static file server
|
|
@@ -76,12 +94,6 @@ const server = http.createServer((req, res) => {
|
|
|
76
94
|
return;
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
// Track mode from hook payload
|
|
80
|
-
if (data.permission_mode && data.permission_mode !== currentMode) {
|
|
81
|
-
currentMode = data.permission_mode;
|
|
82
|
-
broadcast({ type: 'mode', mode: currentMode });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
97
|
if (ALWAYS_AUTO_ALLOW.has(data.tool_name)) {
|
|
86
98
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
87
99
|
res.end(JSON.stringify({ decision: 'allow' }));
|
|
@@ -167,23 +179,32 @@ const server = http.createServer((req, res) => {
|
|
|
167
179
|
// ============================================================
|
|
168
180
|
const wss = new WebSocketServer({ server });
|
|
169
181
|
|
|
170
|
-
function broadcast(msg) {
|
|
171
|
-
const raw = JSON.stringify(msg);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
function broadcast(msg) {
|
|
183
|
+
const raw = JSON.stringify(msg);
|
|
184
|
+
const recipients = [];
|
|
185
|
+
for (const ws of wss.clients) {
|
|
186
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
187
|
+
ws.send(raw);
|
|
188
|
+
recipients.push(wsLabel(ws));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (msg.type === 'working_started' || msg.type === 'turn_complete' || msg.type === 'status' || msg.type === 'transcript_ready') {
|
|
192
|
+
log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
176
195
|
|
|
177
196
|
function latestEventSeq() {
|
|
178
197
|
return eventBuffer.length > 0 ? eventBuffer[eventBuffer.length - 1].seq : 0;
|
|
179
198
|
}
|
|
180
199
|
|
|
181
|
-
function sendReplay(ws, lastSeq = null) {
|
|
182
|
-
const normalizedLastSeq = Number.isInteger(lastSeq) && lastSeq >= 0 ? lastSeq : null;
|
|
183
|
-
const replayFrom = normalizedLastSeq == null ? 0 : normalizedLastSeq;
|
|
184
|
-
const records = replayFrom > 0
|
|
185
|
-
? eventBuffer.filter(record => record.seq > replayFrom)
|
|
186
|
-
: eventBuffer;
|
|
200
|
+
function sendReplay(ws, lastSeq = null) {
|
|
201
|
+
const normalizedLastSeq = Number.isInteger(lastSeq) && lastSeq >= 0 ? lastSeq : null;
|
|
202
|
+
const replayFrom = normalizedLastSeq == null ? 0 : normalizedLastSeq;
|
|
203
|
+
const records = replayFrom > 0
|
|
204
|
+
? eventBuffer.filter(record => record.seq > replayFrom)
|
|
205
|
+
: eventBuffer;
|
|
206
|
+
|
|
207
|
+
log(`Replay start -> ${wsLabel(ws)} from=${replayFrom} count=${records.length} currentSession=${currentSessionId ?? 'null'}`);
|
|
187
208
|
|
|
188
209
|
for (const record of records) {
|
|
189
210
|
ws.send(JSON.stringify({
|
|
@@ -193,13 +214,13 @@ function sendReplay(ws, lastSeq = null) {
|
|
|
193
214
|
}));
|
|
194
215
|
}
|
|
195
216
|
|
|
196
|
-
ws
|
|
197
|
-
type: 'replay_done',
|
|
198
|
-
sessionId: currentSessionId,
|
|
199
|
-
lastSeq: latestEventSeq(),
|
|
200
|
-
resumed: normalizedLastSeq != null,
|
|
201
|
-
})
|
|
202
|
-
}
|
|
217
|
+
sendWs(ws, {
|
|
218
|
+
type: 'replay_done',
|
|
219
|
+
sessionId: currentSessionId,
|
|
220
|
+
lastSeq: latestEventSeq(),
|
|
221
|
+
resumed: normalizedLastSeq != null,
|
|
222
|
+
}, 'sendReplay');
|
|
223
|
+
}
|
|
203
224
|
|
|
204
225
|
function sendUploadStatus(ws, uploadId, status, extra = {}) {
|
|
205
226
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -244,24 +265,28 @@ setInterval(() => {
|
|
|
244
265
|
}
|
|
245
266
|
}, 60 * 1000).unref();
|
|
246
267
|
|
|
247
|
-
wss.on('connection', (ws) => {
|
|
248
|
-
ws.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
268
|
+
wss.on('connection', (ws, req) => {
|
|
269
|
+
ws._bridgeId = ++nextWsId;
|
|
270
|
+
ws._clientInstanceId = '';
|
|
271
|
+
log(`WS connected: ${wsLabel(ws)} remote=${req.socket.remoteAddress || '?'} ua=${JSON.stringify(req.headers['user-agent'] || '')}`);
|
|
272
|
+
|
|
273
|
+
sendWs(ws, {
|
|
274
|
+
type: 'status',
|
|
275
|
+
status: claudeProc ? 'running' : 'starting',
|
|
276
|
+
hasTranscript: !!transcriptPath,
|
|
277
|
+
cwd: CWD,
|
|
278
|
+
sessionId: currentSessionId,
|
|
279
|
+
lastSeq: latestEventSeq(),
|
|
280
|
+
}, 'initial');
|
|
281
|
+
|
|
282
|
+
if (currentSessionId) {
|
|
283
|
+
sendWs(ws, {
|
|
284
|
+
type: 'transcript_ready',
|
|
285
|
+
transcript: transcriptPath,
|
|
286
|
+
sessionId: currentSessionId,
|
|
287
|
+
lastSeq: latestEventSeq(),
|
|
288
|
+
}, 'initial');
|
|
289
|
+
}
|
|
265
290
|
|
|
266
291
|
// New clients should explicitly request a resume window. Keep a delayed
|
|
267
292
|
// full replay fallback so older clients still work.
|
|
@@ -276,12 +301,20 @@ wss.on('connection', (ws) => {
|
|
|
276
301
|
let msg;
|
|
277
302
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
278
303
|
|
|
279
|
-
switch (msg.type) {
|
|
280
|
-
case '
|
|
281
|
-
ws.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
304
|
+
switch (msg.type) {
|
|
305
|
+
case 'hello':
|
|
306
|
+
ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
|
|
307
|
+
log(`WS hello from ${wsLabel(ws)} page=${JSON.stringify(msg.page || '')} ua=${JSON.stringify(msg.userAgent || '')}`);
|
|
308
|
+
break;
|
|
309
|
+
case 'debug_log':
|
|
310
|
+
if (msg.clientInstanceId) ws._clientInstanceId = String(msg.clientInstanceId);
|
|
311
|
+
log(`ClientDebug ${wsLabel(ws)} event=${msg.event || 'unknown'} detail=${JSON.stringify(msg.detail || {})}`);
|
|
312
|
+
break;
|
|
313
|
+
case 'resume': {
|
|
314
|
+
ws._resumeHandled = true;
|
|
315
|
+
if (ws._legacyReplayTimer) {
|
|
316
|
+
clearTimeout(ws._legacyReplayTimer);
|
|
317
|
+
ws._legacyReplayTimer = null;
|
|
285
318
|
}
|
|
286
319
|
|
|
287
320
|
if (!currentSessionId) {
|
|
@@ -294,17 +327,23 @@ wss.on('connection', (ws) => {
|
|
|
294
327
|
break;
|
|
295
328
|
}
|
|
296
329
|
|
|
297
|
-
const
|
|
298
|
-
msg.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
msg.
|
|
302
|
-
msg.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
330
|
+
const clientServerLastSeq = Number.isInteger(msg.serverLastSeq) && msg.serverLastSeq >= 0
|
|
331
|
+
? msg.serverLastSeq
|
|
332
|
+
: null;
|
|
333
|
+
const canResume = (
|
|
334
|
+
msg.sessionId &&
|
|
335
|
+
msg.sessionId === currentSessionId &&
|
|
336
|
+
Number.isInteger(msg.lastSeq) &&
|
|
337
|
+
msg.lastSeq >= 0 &&
|
|
338
|
+
msg.lastSeq <= latestEventSeq() &&
|
|
339
|
+
(clientServerLastSeq == null || msg.lastSeq <= clientServerLastSeq)
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
log(`Resume request from ${wsLabel(ws)} session=${msg.sessionId ?? 'null'} lastSeq=${msg.lastSeq} serverLastSeq=${clientServerLastSeq ?? 'null'} canResume=${canResume}`);
|
|
343
|
+
|
|
344
|
+
sendReplay(ws, canResume ? msg.lastSeq : null);
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
308
347
|
case 'input':
|
|
309
348
|
// Raw terminal keystrokes from xterm.js in WebUI
|
|
310
349
|
if (claudeProc) claudeProc.write(msg.data);
|
|
@@ -324,6 +363,7 @@ wss.on('connection', (ws) => {
|
|
|
324
363
|
if (/^\/clear\s*$/i.test(text.trim())) {
|
|
325
364
|
markExpectingSwitch();
|
|
326
365
|
}
|
|
366
|
+
broadcast({ type: 'working_started' });
|
|
327
367
|
claudeProc.write(text);
|
|
328
368
|
setTimeout(() => {
|
|
329
369
|
if (claudeProc) claudeProc.write('\r');
|
|
@@ -499,93 +539,16 @@ wss.on('connection', (ws) => {
|
|
|
499
539
|
}
|
|
500
540
|
});
|
|
501
541
|
|
|
502
|
-
ws.on('close', () => {
|
|
503
|
-
if (ws._legacyReplayTimer) {
|
|
504
|
-
clearTimeout(ws._legacyReplayTimer);
|
|
505
|
-
ws._legacyReplayTimer = null;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
|
|
542
|
+
ws.on('close', () => {
|
|
543
|
+
if (ws._legacyReplayTimer) {
|
|
544
|
+
clearTimeout(ws._legacyReplayTimer);
|
|
545
|
+
ws._legacyReplayTimer = null;
|
|
546
|
+
}
|
|
547
|
+
log(`WS closed: ${wsLabel(ws)}`);
|
|
548
|
+
cleanupClientUploads(ws);
|
|
549
|
+
});
|
|
509
550
|
});
|
|
510
551
|
|
|
511
|
-
// ============================================================
|
|
512
|
-
// 3. PTY Mode Detection (ANSI side-channel parsing)
|
|
513
|
-
// ============================================================
|
|
514
|
-
let ptyTextBuf = '';
|
|
515
|
-
|
|
516
|
-
function stripAnsi(s) {
|
|
517
|
-
// eslint-disable-next-line no-control-regex
|
|
518
|
-
return s.replace(/\x1B(?:\[[\x20-\x3f]*[0-9;]*[A-Za-z]|\].*?(?:\x07|\x1B\\)|\([B0])/g, '');
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function detectModeFromPTY(data) {
|
|
522
|
-
// Ink (Claude Code's TUI framework) redraws by sending cursor-up
|
|
523
|
-
// sequences (\x1B[<n>A) followed by new content. When we detect a
|
|
524
|
-
// redraw we MUST clear the accumulated text buffer, otherwise stale
|
|
525
|
-
// mode keywords from previous renders linger and cause false matches.
|
|
526
|
-
if (/\x1B\[\d*A/.test(data)) {
|
|
527
|
-
ptyTextBuf = '';
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
ptyTextBuf += stripAnsi(data);
|
|
531
|
-
if (ptyTextBuf.length > 4000) ptyTextBuf = ptyTextBuf.slice(-2000);
|
|
532
|
-
|
|
533
|
-
const tail = ptyTextBuf.slice(-500);
|
|
534
|
-
const lc = tail.toLowerCase();
|
|
535
|
-
|
|
536
|
-
let detected = null;
|
|
537
|
-
|
|
538
|
-
// The status bar always contains "for shortcuts".
|
|
539
|
-
// (Older versions: "shift+tab to cycle", newer: "? for shortcuts")
|
|
540
|
-
const anchorIdx = Math.max(lc.lastIndexOf('for shortcuts'), lc.lastIndexOf('shift+tab'));
|
|
541
|
-
|
|
542
|
-
if (anchorIdx >= 0) {
|
|
543
|
-
// Inspect ~80 chars BEFORE and AFTER the anchor.
|
|
544
|
-
// The mode label can appear on either side depending on status bar layout:
|
|
545
|
-
// "⏸ plan mode on ? for shortcuts" ← mode BEFORE anchor
|
|
546
|
-
// "? for shortcuts ⏵⏵ accept edits" ← mode AFTER anchor
|
|
547
|
-
const before = lc.slice(Math.max(0, anchorIdx - 80), anchorIdx);
|
|
548
|
-
const after = lc.slice(anchorIdx, Math.min(lc.length, anchorIdx + 80));
|
|
549
|
-
const win = before + after;
|
|
550
|
-
log(`Mode window: [${win}]`);
|
|
551
|
-
|
|
552
|
-
if (win.includes('plan')) {
|
|
553
|
-
detected = 'plan';
|
|
554
|
-
} else if (win.includes('accept')) {
|
|
555
|
-
detected = 'acceptEdits';
|
|
556
|
-
} else if (win.includes('bypass')) {
|
|
557
|
-
detected = 'bypassPermissions';
|
|
558
|
-
} else {
|
|
559
|
-
// Status bar present but no mode keyword → default
|
|
560
|
-
detected = 'default';
|
|
561
|
-
}
|
|
562
|
-
} else {
|
|
563
|
-
// No status-bar anchor — check for explicit toggle messages
|
|
564
|
-
// that Claude prints when mode changes (e.g. "⏸ plan mode on")
|
|
565
|
-
if (/plan mode on/i.test(lc) || /\u23F8\s*plan/i.test(tail)) {
|
|
566
|
-
detected = 'plan';
|
|
567
|
-
} else if (/accept edits on/i.test(lc) || /\u23F5\u23F5\s*accept/i.test(tail)) {
|
|
568
|
-
detected = 'acceptEdits';
|
|
569
|
-
} else if (/bypass.*on/i.test(lc)) {
|
|
570
|
-
detected = 'bypassPermissions';
|
|
571
|
-
}
|
|
572
|
-
// Check if buffer has status-bar content but anchor was mangled
|
|
573
|
-
// If we see mode indicators like ⏸ or ⏵⏵ without explicit text
|
|
574
|
-
else if (tail.includes('\u23F8')) {
|
|
575
|
-
detected = 'plan';
|
|
576
|
-
} else if (tail.includes('\u23F5\u23F5')) {
|
|
577
|
-
detected = 'acceptEdits';
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
if (detected && detected !== currentMode) {
|
|
582
|
-
currentMode = detected;
|
|
583
|
-
broadcast({ type: 'mode', mode: currentMode });
|
|
584
|
-
log(`Mode detected from PTY: ${currentMode}`);
|
|
585
|
-
ptyTextBuf = ''; // reset after detection
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
552
|
// ============================================================
|
|
590
553
|
// 4. PTY Manager — local terminal passthrough
|
|
591
554
|
// ============================================================
|
|
@@ -617,7 +580,6 @@ function spawnClaude() {
|
|
|
617
580
|
claudeProc.onData((data) => {
|
|
618
581
|
if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
|
|
619
582
|
broadcast({ type: 'pty_output', data }); // push to WebUI
|
|
620
|
-
detectModeFromPTY(data);
|
|
621
583
|
});
|
|
622
584
|
|
|
623
585
|
// === Local terminal input → PTY ===
|
|
@@ -851,6 +813,7 @@ function startTailing() {
|
|
|
851
813
|
const event = JSON.parse(line);
|
|
852
814
|
// Detect /clear from JSONL events (covers terminal direct input)
|
|
853
815
|
if (event.type === 'user' || (event.message && event.message.role === 'user')) {
|
|
816
|
+
broadcast({ type: 'working_started' });
|
|
854
817
|
const content = event.message && event.message.content;
|
|
855
818
|
if (typeof content === 'string' && /^\/clear\s*$/i.test(content.trim())) {
|
|
856
819
|
markExpectingSwitch();
|