claude-remote 0.1.0 → 0.1.2
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/hooks/bridge-session-start.js +32 -0
- package/package.json +1 -1
- package/server.js +232 -263
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 一键启动
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bridge session-start hook — binds the spawned Claude session to its transcript.
|
|
3
|
+
|
|
4
|
+
const http = require('http');
|
|
5
|
+
|
|
6
|
+
if (!process.env.BRIDGE_PORT) process.exit(0);
|
|
7
|
+
|
|
8
|
+
const PORT = process.env.BRIDGE_PORT;
|
|
9
|
+
|
|
10
|
+
let input = '';
|
|
11
|
+
process.stdin.setEncoding('utf8');
|
|
12
|
+
process.stdin.on('data', chunk => (input += chunk));
|
|
13
|
+
process.stdin.on('end', () => {
|
|
14
|
+
const body = input || '{}';
|
|
15
|
+
const req = http.request({
|
|
16
|
+
hostname: '127.0.0.1',
|
|
17
|
+
port: PORT,
|
|
18
|
+
path: '/hook/session-start',
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'Content-Length': Buffer.byteLength(body),
|
|
23
|
+
},
|
|
24
|
+
}, () => {
|
|
25
|
+
process.exit(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
req.on('error', () => process.exit(0));
|
|
29
|
+
req.setTimeout(10000, () => { req.destroy(); process.exit(0); });
|
|
30
|
+
req.write(body);
|
|
31
|
+
req.end();
|
|
32
|
+
});
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -16,18 +16,16 @@ 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
|
|
24
|
-
let
|
|
25
|
-
let switchWatcher = null;
|
|
26
|
-
let expectingSwitch = false;
|
|
27
|
-
let expectingSwitchTimer = null;
|
|
28
|
-
let
|
|
29
|
-
let preExistingFileSizes = new Map();
|
|
30
|
-
let tailRemainder = Buffer.alloc(0);
|
|
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;
|
|
25
|
+
let switchWatcher = null;
|
|
26
|
+
let expectingSwitch = false;
|
|
27
|
+
let expectingSwitchTimer = null;
|
|
28
|
+
let tailRemainder = Buffer.alloc(0);
|
|
31
29
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
32
30
|
const LEGACY_REPLAY_DELAY_MS = 1500;
|
|
33
31
|
const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
|
@@ -36,7 +34,6 @@ const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
|
|
36
34
|
let approvalSeq = 0;
|
|
37
35
|
const pendingApprovals = new Map(); // id → { res, timer }
|
|
38
36
|
const pendingImageUploads = new Map();
|
|
39
|
-
let currentMode = 'default';
|
|
40
37
|
let approvalMode = 'default'; // 'default' | 'partial' | 'all'
|
|
41
38
|
const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
|
|
42
39
|
const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
|
|
@@ -44,10 +41,79 @@ const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
|
|
|
44
41
|
// --- Logging → file only (never pollute the terminal) ---
|
|
45
42
|
const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
|
|
46
43
|
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
|
-
}
|
|
44
|
+
function log(msg) {
|
|
45
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
46
|
+
fs.appendFileSync(LOG_FILE, line);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function wsLabel(ws) {
|
|
50
|
+
const clientId = ws && ws._clientInstanceId ? ` client=${ws._clientInstanceId}` : '';
|
|
51
|
+
return `ws#${ws && ws._bridgeId ? ws._bridgeId : '?'}${clientId}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendWs(ws, msg, context = '') {
|
|
55
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
56
|
+
ws.send(JSON.stringify(msg));
|
|
57
|
+
if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'replay_done') {
|
|
58
|
+
const extra = [];
|
|
59
|
+
if (msg.sessionId !== undefined) extra.push(`session=${msg.sessionId ?? 'null'}`);
|
|
60
|
+
if (msg.lastSeq !== undefined) extra.push(`lastSeq=${msg.lastSeq}`);
|
|
61
|
+
if (msg.resumed !== undefined) extra.push(`resumed=${msg.resumed}`);
|
|
62
|
+
log(`Send ${msg.type}${context ? ` (${context})` : ''} -> ${wsLabel(ws)}${extra.length ? ` ${extra.join(' ')}` : ''}`);
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeFsPath(value) {
|
|
68
|
+
const resolved = path.resolve(String(value || ''));
|
|
69
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function projectTranscriptDir() {
|
|
73
|
+
return path.join(PROJECTS_DIR, getProjectSlug(CWD));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveHookTranscript(data) {
|
|
77
|
+
if (!data || typeof data !== 'object') return null;
|
|
78
|
+
|
|
79
|
+
const hookCwd = data.cwd ? path.resolve(String(data.cwd)) : '';
|
|
80
|
+
if (hookCwd && normalizeFsPath(hookCwd) !== normalizeFsPath(CWD)) return null;
|
|
81
|
+
|
|
82
|
+
const sessionId = data.session_id ? String(data.session_id) : '';
|
|
83
|
+
const expectedDir = projectTranscriptDir();
|
|
84
|
+
const transcriptPath = data.transcript_path ? path.resolve(String(data.transcript_path)) : '';
|
|
85
|
+
|
|
86
|
+
if (transcriptPath) {
|
|
87
|
+
const transcriptDir = path.dirname(transcriptPath);
|
|
88
|
+
const transcriptSessionId = path.basename(transcriptPath, '.jsonl');
|
|
89
|
+
const dirMatches = normalizeFsPath(transcriptDir) === normalizeFsPath(expectedDir);
|
|
90
|
+
const idMatches = !sessionId || transcriptSessionId === sessionId;
|
|
91
|
+
if (dirMatches && idMatches) {
|
|
92
|
+
return { full: transcriptPath, sessionId: transcriptSessionId };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!sessionId) return null;
|
|
97
|
+
return { full: path.join(expectedDir, `${sessionId}.jsonl`), sessionId };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function maybeAttachHookSession(data, source) {
|
|
101
|
+
const target = resolveHookTranscript(data);
|
|
102
|
+
if (!target) return;
|
|
103
|
+
|
|
104
|
+
if (currentSessionId === target.sessionId && transcriptPath &&
|
|
105
|
+
normalizeFsPath(transcriptPath) === normalizeFsPath(target.full)) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (currentSessionId && currentSessionId !== target.sessionId && !expectingSwitch) {
|
|
110
|
+
log(`Ignored hook session from ${source}: ${target.sessionId} (current=${currentSessionId})`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
log(`Hook session attached from ${source}: ${target.sessionId}`);
|
|
115
|
+
attachTranscript({ full: target.full }, 0);
|
|
116
|
+
}
|
|
51
117
|
|
|
52
118
|
// ============================================================
|
|
53
119
|
// 1. Static file server
|
|
@@ -76,13 +142,9 @@ const server = http.createServer((req, res) => {
|
|
|
76
142
|
return;
|
|
77
143
|
}
|
|
78
144
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
broadcast({ type: 'mode', mode: currentMode });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (ALWAYS_AUTO_ALLOW.has(data.tool_name)) {
|
|
145
|
+
maybeAttachHookSession(data, 'pre-tool-use');
|
|
146
|
+
|
|
147
|
+
if (ALWAYS_AUTO_ALLOW.has(data.tool_name)) {
|
|
86
148
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
87
149
|
res.end(JSON.stringify({ decision: 'allow' }));
|
|
88
150
|
log(`Permission auto-allowed (always): ${data.tool_name}`);
|
|
@@ -135,13 +197,30 @@ const server = http.createServer((req, res) => {
|
|
|
135
197
|
return;
|
|
136
198
|
}
|
|
137
199
|
|
|
138
|
-
// --- API:
|
|
200
|
+
// --- API: Session start hook endpoint ---
|
|
201
|
+
if (req.method === 'POST' && url === '/hook/session-start') {
|
|
202
|
+
let body = '';
|
|
203
|
+
req.on('data', chunk => (body += chunk));
|
|
204
|
+
req.on('end', () => {
|
|
205
|
+
try {
|
|
206
|
+
maybeAttachHookSession(JSON.parse(body), 'session-start');
|
|
207
|
+
} catch {}
|
|
208
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
209
|
+
res.end('{}');
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- API: Stop hook endpoint ---
|
|
139
215
|
if (req.method === 'POST' && url === '/hook/stop') {
|
|
140
216
|
let body = '';
|
|
141
217
|
req.on('data', chunk => (body += chunk));
|
|
142
218
|
req.on('end', () => {
|
|
143
219
|
log('/hook/stop received — broadcasting turn_complete');
|
|
144
|
-
|
|
220
|
+
try {
|
|
221
|
+
maybeAttachHookSession(JSON.parse(body), 'stop');
|
|
222
|
+
} catch {}
|
|
223
|
+
broadcast({ type: 'turn_complete' });
|
|
145
224
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
146
225
|
res.end('{}');
|
|
147
226
|
});
|
|
@@ -167,23 +246,32 @@ const server = http.createServer((req, res) => {
|
|
|
167
246
|
// ============================================================
|
|
168
247
|
const wss = new WebSocketServer({ server });
|
|
169
248
|
|
|
170
|
-
function broadcast(msg) {
|
|
171
|
-
const raw = JSON.stringify(msg);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
249
|
+
function broadcast(msg) {
|
|
250
|
+
const raw = JSON.stringify(msg);
|
|
251
|
+
const recipients = [];
|
|
252
|
+
for (const ws of wss.clients) {
|
|
253
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
254
|
+
ws.send(raw);
|
|
255
|
+
recipients.push(wsLabel(ws));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (msg.type === 'working_started' || msg.type === 'turn_complete' || msg.type === 'status' || msg.type === 'transcript_ready') {
|
|
259
|
+
log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
176
262
|
|
|
177
263
|
function latestEventSeq() {
|
|
178
264
|
return eventBuffer.length > 0 ? eventBuffer[eventBuffer.length - 1].seq : 0;
|
|
179
265
|
}
|
|
180
266
|
|
|
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;
|
|
267
|
+
function sendReplay(ws, lastSeq = null) {
|
|
268
|
+
const normalizedLastSeq = Number.isInteger(lastSeq) && lastSeq >= 0 ? lastSeq : null;
|
|
269
|
+
const replayFrom = normalizedLastSeq == null ? 0 : normalizedLastSeq;
|
|
270
|
+
const records = replayFrom > 0
|
|
271
|
+
? eventBuffer.filter(record => record.seq > replayFrom)
|
|
272
|
+
: eventBuffer;
|
|
273
|
+
|
|
274
|
+
log(`Replay start -> ${wsLabel(ws)} from=${replayFrom} count=${records.length} currentSession=${currentSessionId ?? 'null'}`);
|
|
187
275
|
|
|
188
276
|
for (const record of records) {
|
|
189
277
|
ws.send(JSON.stringify({
|
|
@@ -193,13 +281,13 @@ function sendReplay(ws, lastSeq = null) {
|
|
|
193
281
|
}));
|
|
194
282
|
}
|
|
195
283
|
|
|
196
|
-
ws
|
|
197
|
-
type: 'replay_done',
|
|
198
|
-
sessionId: currentSessionId,
|
|
199
|
-
lastSeq: latestEventSeq(),
|
|
200
|
-
resumed: normalizedLastSeq != null,
|
|
201
|
-
})
|
|
202
|
-
}
|
|
284
|
+
sendWs(ws, {
|
|
285
|
+
type: 'replay_done',
|
|
286
|
+
sessionId: currentSessionId,
|
|
287
|
+
lastSeq: latestEventSeq(),
|
|
288
|
+
resumed: normalizedLastSeq != null,
|
|
289
|
+
}, 'sendReplay');
|
|
290
|
+
}
|
|
203
291
|
|
|
204
292
|
function sendUploadStatus(ws, uploadId, status, extra = {}) {
|
|
205
293
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -244,24 +332,28 @@ setInterval(() => {
|
|
|
244
332
|
}
|
|
245
333
|
}, 60 * 1000).unref();
|
|
246
334
|
|
|
247
|
-
wss.on('connection', (ws) => {
|
|
248
|
-
ws.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
335
|
+
wss.on('connection', (ws, req) => {
|
|
336
|
+
ws._bridgeId = ++nextWsId;
|
|
337
|
+
ws._clientInstanceId = '';
|
|
338
|
+
log(`WS connected: ${wsLabel(ws)} remote=${req.socket.remoteAddress || '?'} ua=${JSON.stringify(req.headers['user-agent'] || '')}`);
|
|
339
|
+
|
|
340
|
+
sendWs(ws, {
|
|
341
|
+
type: 'status',
|
|
342
|
+
status: claudeProc ? 'running' : 'starting',
|
|
343
|
+
hasTranscript: !!transcriptPath,
|
|
344
|
+
cwd: CWD,
|
|
345
|
+
sessionId: currentSessionId,
|
|
346
|
+
lastSeq: latestEventSeq(),
|
|
347
|
+
}, 'initial');
|
|
348
|
+
|
|
349
|
+
if (currentSessionId) {
|
|
350
|
+
sendWs(ws, {
|
|
351
|
+
type: 'transcript_ready',
|
|
352
|
+
transcript: transcriptPath,
|
|
353
|
+
sessionId: currentSessionId,
|
|
354
|
+
lastSeq: latestEventSeq(),
|
|
355
|
+
}, 'initial');
|
|
356
|
+
}
|
|
265
357
|
|
|
266
358
|
// New clients should explicitly request a resume window. Keep a delayed
|
|
267
359
|
// full replay fallback so older clients still work.
|
|
@@ -276,12 +368,20 @@ wss.on('connection', (ws) => {
|
|
|
276
368
|
let msg;
|
|
277
369
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
278
370
|
|
|
279
|
-
switch (msg.type) {
|
|
280
|
-
case '
|
|
281
|
-
ws.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
371
|
+
switch (msg.type) {
|
|
372
|
+
case 'hello':
|
|
373
|
+
ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
|
|
374
|
+
log(`WS hello from ${wsLabel(ws)} page=${JSON.stringify(msg.page || '')} ua=${JSON.stringify(msg.userAgent || '')}`);
|
|
375
|
+
break;
|
|
376
|
+
case 'debug_log':
|
|
377
|
+
if (msg.clientInstanceId) ws._clientInstanceId = String(msg.clientInstanceId);
|
|
378
|
+
log(`ClientDebug ${wsLabel(ws)} event=${msg.event || 'unknown'} detail=${JSON.stringify(msg.detail || {})}`);
|
|
379
|
+
break;
|
|
380
|
+
case 'resume': {
|
|
381
|
+
ws._resumeHandled = true;
|
|
382
|
+
if (ws._legacyReplayTimer) {
|
|
383
|
+
clearTimeout(ws._legacyReplayTimer);
|
|
384
|
+
ws._legacyReplayTimer = null;
|
|
285
385
|
}
|
|
286
386
|
|
|
287
387
|
if (!currentSessionId) {
|
|
@@ -294,17 +394,23 @@ wss.on('connection', (ws) => {
|
|
|
294
394
|
break;
|
|
295
395
|
}
|
|
296
396
|
|
|
297
|
-
const
|
|
298
|
-
msg.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
msg.
|
|
302
|
-
msg.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
397
|
+
const clientServerLastSeq = Number.isInteger(msg.serverLastSeq) && msg.serverLastSeq >= 0
|
|
398
|
+
? msg.serverLastSeq
|
|
399
|
+
: null;
|
|
400
|
+
const canResume = (
|
|
401
|
+
msg.sessionId &&
|
|
402
|
+
msg.sessionId === currentSessionId &&
|
|
403
|
+
Number.isInteger(msg.lastSeq) &&
|
|
404
|
+
msg.lastSeq >= 0 &&
|
|
405
|
+
msg.lastSeq <= latestEventSeq() &&
|
|
406
|
+
(clientServerLastSeq == null || msg.lastSeq <= clientServerLastSeq)
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
log(`Resume request from ${wsLabel(ws)} session=${msg.sessionId ?? 'null'} lastSeq=${msg.lastSeq} serverLastSeq=${clientServerLastSeq ?? 'null'} canResume=${canResume}`);
|
|
410
|
+
|
|
411
|
+
sendReplay(ws, canResume ? msg.lastSeq : null);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
308
414
|
case 'input':
|
|
309
415
|
// Raw terminal keystrokes from xterm.js in WebUI
|
|
310
416
|
if (claudeProc) claudeProc.write(msg.data);
|
|
@@ -324,6 +430,7 @@ wss.on('connection', (ws) => {
|
|
|
324
430
|
if (/^\/clear\s*$/i.test(text.trim())) {
|
|
325
431
|
markExpectingSwitch();
|
|
326
432
|
}
|
|
433
|
+
broadcast({ type: 'working_started' });
|
|
327
434
|
claudeProc.write(text);
|
|
328
435
|
setTimeout(() => {
|
|
329
436
|
if (claudeProc) claudeProc.write('\r');
|
|
@@ -499,100 +606,21 @@ wss.on('connection', (ws) => {
|
|
|
499
606
|
}
|
|
500
607
|
});
|
|
501
608
|
|
|
502
|
-
ws.on('close', () => {
|
|
503
|
-
if (ws._legacyReplayTimer) {
|
|
504
|
-
clearTimeout(ws._legacyReplayTimer);
|
|
505
|
-
ws._legacyReplayTimer = null;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
|
|
609
|
+
ws.on('close', () => {
|
|
610
|
+
if (ws._legacyReplayTimer) {
|
|
611
|
+
clearTimeout(ws._legacyReplayTimer);
|
|
612
|
+
ws._legacyReplayTimer = null;
|
|
613
|
+
}
|
|
614
|
+
log(`WS closed: ${wsLabel(ws)}`);
|
|
615
|
+
cleanupClientUploads(ws);
|
|
616
|
+
});
|
|
509
617
|
});
|
|
510
618
|
|
|
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
619
|
// ============================================================
|
|
590
620
|
// 4. PTY Manager — local terminal passthrough
|
|
591
621
|
// ============================================================
|
|
592
|
-
function spawnClaude() {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const isWin = process.platform === 'win32';
|
|
622
|
+
function spawnClaude() {
|
|
623
|
+
const isWin = process.platform === 'win32';
|
|
596
624
|
const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
|
|
597
625
|
const args = isWin
|
|
598
626
|
? ['-NoLogo', '-NoProfile', '-Command', 'claude']
|
|
@@ -617,7 +645,6 @@ function spawnClaude() {
|
|
|
617
645
|
claudeProc.onData((data) => {
|
|
618
646
|
if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
|
|
619
647
|
broadcast({ type: 'pty_output', data }); // push to WebUI
|
|
620
|
-
detectModeFromPTY(data);
|
|
621
648
|
});
|
|
622
649
|
|
|
623
650
|
// === Local terminal input → PTY ===
|
|
@@ -706,82 +733,9 @@ function attachTranscript(target, startOffset = 0) {
|
|
|
706
733
|
sessionId: currentSessionId,
|
|
707
734
|
lastSeq: 0,
|
|
708
735
|
});
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
discoveryTimer = null;
|
|
713
|
-
}
|
|
714
|
-
startTailing();
|
|
715
|
-
startSwitchWatcher();
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function snapshotExistingFiles() {
|
|
719
|
-
const slug = getProjectSlug(CWD);
|
|
720
|
-
const projectDir = path.join(PROJECTS_DIR, slug);
|
|
721
|
-
preExistingFiles.clear();
|
|
722
|
-
preExistingFileSizes.clear();
|
|
723
|
-
try {
|
|
724
|
-
if (fs.existsSync(projectDir)) {
|
|
725
|
-
for (const f of fs.readdirSync(projectDir)) {
|
|
726
|
-
if (!f.endsWith('.jsonl')) continue;
|
|
727
|
-
const full = path.join(projectDir, f);
|
|
728
|
-
const stat = fs.statSync(full);
|
|
729
|
-
preExistingFiles.add(f);
|
|
730
|
-
preExistingFileSizes.set(f, stat.size);
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
} catch {}
|
|
734
|
-
log(`Pre-existing transcripts: ${preExistingFiles.size} files`);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function startDiscovery() {
|
|
738
|
-
const slug = getProjectSlug(CWD);
|
|
739
|
-
const projectDir = path.join(PROJECTS_DIR, slug);
|
|
740
|
-
log(`Watching for NEW transcript in: ${projectDir}`);
|
|
741
|
-
|
|
742
|
-
discoveryTimer = setInterval(() => {
|
|
743
|
-
if (!fs.existsSync(projectDir)) return;
|
|
744
|
-
|
|
745
|
-
try {
|
|
746
|
-
const targets = fs.readdirSync(projectDir)
|
|
747
|
-
.filter(f => f.endsWith('.jsonl'))
|
|
748
|
-
.map(f => {
|
|
749
|
-
const full = path.join(projectDir, f);
|
|
750
|
-
const stat = fs.statSync(full);
|
|
751
|
-
return {
|
|
752
|
-
name: f,
|
|
753
|
-
full,
|
|
754
|
-
mtime: stat.mtimeMs,
|
|
755
|
-
size: stat.size,
|
|
756
|
-
};
|
|
757
|
-
})
|
|
758
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
759
|
-
|
|
760
|
-
const newTargets = targets.filter(t => !preExistingFiles.has(t.name));
|
|
761
|
-
const newTranscript = newTargets.find(t => fileLooksLikeTranscript(t.full));
|
|
762
|
-
if (newTranscript) {
|
|
763
|
-
log(`NEW transcript found: ${path.basename(newTranscript.full, '.jsonl')}`);
|
|
764
|
-
attachTranscript(newTranscript, 0);
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
for (const t of newTargets) {
|
|
769
|
-
preExistingFiles.add(t.name);
|
|
770
|
-
preExistingFileSizes.set(t.name, t.size);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// Fallback: reuse a pre-existing transcript if it keeps growing.
|
|
774
|
-
const grownTargets = targets.filter(t => t.size > (preExistingFileSizes.get(t.name) || 0));
|
|
775
|
-
const grownTranscript = grownTargets.find(t => fileLooksLikeTranscript(t.full));
|
|
776
|
-
if (grownTranscript) {
|
|
777
|
-
const baseOffset = preExistingFileSizes.get(grownTranscript.name) || 0;
|
|
778
|
-
log(`Reusing growing transcript: ${path.basename(grownTranscript.full, '.jsonl')} (from offset ${baseOffset})`);
|
|
779
|
-
attachTranscript(grownTranscript, baseOffset);
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
} catch {}
|
|
783
|
-
}, 500);
|
|
784
|
-
}
|
|
736
|
+
startTailing();
|
|
737
|
+
startSwitchWatcher();
|
|
738
|
+
}
|
|
785
739
|
|
|
786
740
|
function markExpectingSwitch() {
|
|
787
741
|
expectingSwitch = true;
|
|
@@ -851,6 +805,7 @@ function startTailing() {
|
|
|
851
805
|
const event = JSON.parse(line);
|
|
852
806
|
// Detect /clear from JSONL events (covers terminal direct input)
|
|
853
807
|
if (event.type === 'user' || (event.message && event.message.role === 'user')) {
|
|
808
|
+
broadcast({ type: 'working_started' });
|
|
854
809
|
const content = event.message && event.message.content;
|
|
855
810
|
if (typeof content === 'string' && /^\/clear\s*$/i.test(content.trim())) {
|
|
856
811
|
markExpectingSwitch();
|
|
@@ -873,12 +828,11 @@ function startTailing() {
|
|
|
873
828
|
}, 300);
|
|
874
829
|
}
|
|
875
830
|
|
|
876
|
-
function stopTailing() {
|
|
877
|
-
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
878
|
-
if (
|
|
879
|
-
if (
|
|
880
|
-
|
|
881
|
-
expectingSwitch = false;
|
|
831
|
+
function stopTailing() {
|
|
832
|
+
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
833
|
+
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
834
|
+
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
835
|
+
expectingSwitch = false;
|
|
882
836
|
tailRemainder = Buffer.alloc(0);
|
|
883
837
|
}
|
|
884
838
|
|
|
@@ -1015,12 +969,28 @@ function setupHooks() {
|
|
|
1015
969
|
existingStop[stopBridgeIdx] = stopEntry;
|
|
1016
970
|
} else {
|
|
1017
971
|
existingStop.push(stopEntry);
|
|
1018
|
-
}
|
|
1019
|
-
settings.hooks.Stop = existingStop;
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
972
|
+
}
|
|
973
|
+
settings.hooks.Stop = existingStop;
|
|
974
|
+
|
|
975
|
+
const sessionStartScript = path.resolve(__dirname, 'hooks', 'bridge-session-start.js').replace(/\\/g, '/');
|
|
976
|
+
const sessionStartCmd = `node "${sessionStartScript}"`;
|
|
977
|
+
const existingSessionStart = settings.hooks.SessionStart || [];
|
|
978
|
+
const sessionStartBridgeIdx = existingSessionStart.findIndex(e =>
|
|
979
|
+
e.hooks?.some(h => h.command?.includes('bridge-session-start'))
|
|
980
|
+
);
|
|
981
|
+
const sessionStartEntry = {
|
|
982
|
+
hooks: [{ type: 'command', command: sessionStartCmd, timeout: 10 }],
|
|
983
|
+
};
|
|
984
|
+
if (sessionStartBridgeIdx >= 0) {
|
|
985
|
+
existingSessionStart[sessionStartBridgeIdx] = sessionStartEntry;
|
|
986
|
+
} else {
|
|
987
|
+
existingSessionStart.push(sessionStartEntry);
|
|
988
|
+
}
|
|
989
|
+
settings.hooks.SessionStart = existingSessionStart;
|
|
990
|
+
|
|
991
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
992
|
+
log(`Hooks configured: ${settingsPath}`);
|
|
993
|
+
}
|
|
1024
994
|
|
|
1025
995
|
// ============================================================
|
|
1026
996
|
// 7. Startup
|
|
@@ -1051,8 +1021,7 @@ server.listen(PORT, '0.0.0.0', () => {
|
|
|
1051
1021
|
Phone: ${lan}
|
|
1052
1022
|
─────────────────────────────
|
|
1053
1023
|
|
|
1054
|
-
`);
|
|
1055
|
-
setupHooks();
|
|
1056
|
-
spawnClaude();
|
|
1057
|
-
|
|
1058
|
-
});
|
|
1024
|
+
`);
|
|
1025
|
+
setupHooks();
|
|
1026
|
+
spawnClaude();
|
|
1027
|
+
});
|