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 CHANGED
@@ -1,10 +1,10 @@
1
- # Claude API Remote Control
1
+ # Claude Remote
2
2
 
3
3
  > 在手机上远程操控 Claude Code —— 随时随地写代码、审批权限、切换模型。
4
4
 
5
5
  ## 这是什么?
6
6
 
7
- 一个轻量级的远程控制桥接器,让你可以通过手机浏览器或 Android App 连接到电脑上运行的 Claude Code,实现:
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
- ┌─────────────┐ WebSocket ┌──────────────┐ PTY ┌────────────┐
19
- │ 手机浏览器 │ ◄──────────────► │ server.js │ ◄──────────► │ Claude Code│
20
- │ / App │ ws://ip:3100 │ (Bridge) │ node-pty │ (CLI) │
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
- **server.js** 是核心桥接层:
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
- ### 1. 启动 Bridge Server
33
+ 指定端口:
37
34
 
38
35
  ```bash
39
- # 首次运行先安装依赖
40
- cd /path/to/claudecode_api_RemoteControl
41
- npm install
36
+ PORT=8080 claude-remote
37
+ ```
38
+
39
+ ### 3. App 连接
40
+
41
+ 安装 Android App 后,在设置页输入服务器地址连接。根据你的网络环境,有以下三种方式:
42
42
 
43
- # 在任意项目目录下,用完整路径启动(默认端口 3100)
44
- node /path/to/claudecode_api_RemoteControl/server.js
43
+ #### 方式一:局域网直连
45
44
 
46
- # 或指定端口
47
- PORT=8080 node /path/to/claudecode_api_RemoteControl/server.js
45
+ 手机和电脑在同一 Wi-Fi 下,直接输入电脑内网 IP:
46
+
47
+ ```
48
+ ws://<电脑IP>:3100
48
49
  ```
49
50
 
50
- ### 2. 手机浏览器访问
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
- http://<电脑IP>:3100
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
- ### 3. Android App(可选)
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
- App 启动后输入 server 地址连接即可。
68
-
69
- ## 项目结构
96
+ ## 架构
70
97
 
71
98
  ```
72
- ├── server.js # Bridge 服务器(HTTP + WebSocket + PTY
73
- ├── web/
74
- └── index.html # Web UI(单文件 SPA)
75
- ├── app/ # Tauri 2.0 Android 客户端
76
- ├── src/
77
- │ ├── index.html # 入口页面(含连接页)
78
- │ │ ├── main.js # 业务逻辑
79
- │ │ └── styles.css # 样式
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
- | 外部链接 | 点击链接跳转系统浏览器,不影响 WebView |
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(可选,需要 Rust 工具链)
136
+ - **Tauri 2.0** —— Android App(需要 Rust 工具链)
108
137
 
109
138
  ## TODO
110
139
 
111
- > 本项目看到 Claude Code 官方远程控制,可惜不支持api调用的模式,于是空闲时间摸鱼用 vibing 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Remote control bridge for Claude Code REPL - drive from phone/WebUI",
5
5
  "main": "server.js",
6
6
  "bin": {
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 tailTimer = null;
24
- let discoveryTimer = null;
25
- let switchWatcher = null;
26
- let expectingSwitch = false;
27
- let expectingSwitchTimer = null;
28
- let preExistingFiles = new Set();
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
- // 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
- 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: Stop hook endpoint ---
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
- broadcast({ type: 'turn_complete' });
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
- for (const ws of wss.clients) {
173
- if (ws.readyState === WebSocket.OPEN) ws.send(raw);
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.send(JSON.stringify({
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.send(JSON.stringify({
249
- type: 'status',
250
- status: claudeProc ? 'running' : 'starting',
251
- hasTranscript: !!transcriptPath,
252
- cwd: CWD,
253
- sessionId: currentSessionId,
254
- lastSeq: latestEventSeq(),
255
- }));
256
-
257
- if (currentSessionId) {
258
- ws.send(JSON.stringify({
259
- type: 'transcript_ready',
260
- transcript: transcriptPath,
261
- sessionId: currentSessionId,
262
- lastSeq: latestEventSeq(),
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 'resume': {
281
- ws._resumeHandled = true;
282
- if (ws._legacyReplayTimer) {
283
- clearTimeout(ws._legacyReplayTimer);
284
- ws._legacyReplayTimer = null;
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 canResume = (
298
- msg.sessionId &&
299
- msg.sessionId === currentSessionId &&
300
- Number.isInteger(msg.lastSeq) &&
301
- msg.lastSeq >= 0 &&
302
- msg.lastSeq <= latestEventSeq()
303
- );
304
-
305
- sendReplay(ws, canResume ? msg.lastSeq : null);
306
- break;
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
- cleanupClientUploads(ws);
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
- snapshotExistingFiles();
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
- if (discoveryTimer) {
711
- clearInterval(discoveryTimer);
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 (discoveryTimer) { clearInterval(discoveryTimer); discoveryTimer = null; }
879
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
880
- if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
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
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1022
- log(`Hooks configured: ${settingsPath}`);
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
- startDiscovery();
1058
- });
1024
+ `);
1025
+ setupHooks();
1026
+ spawnClaude();
1027
+ });