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.
Files changed (3) hide show
  1. package/README.md +80 -50
  2. package/package.json +1 -1
  3. package/server.js +119 -156
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 一键启动
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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,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 tailTimer = null;
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
- for (const ws of wss.clients) {
173
- if (ws.readyState === WebSocket.OPEN) ws.send(raw);
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.send(JSON.stringify({
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.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
- }
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 'resume': {
281
- ws._resumeHandled = true;
282
- if (ws._legacyReplayTimer) {
283
- clearTimeout(ws._legacyReplayTimer);
284
- ws._legacyReplayTimer = null;
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 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
- }
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
- cleanupClientUploads(ws);
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();