claude-remote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -0
- package/bin/claude-remote.js +2 -0
- package/hooks/bridge-approval.js +69 -0
- package/hooks/bridge-stop.js +37 -0
- package/package.json +21 -0
- package/server.js +1058 -0
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Claude API Remote Control
|
|
2
|
+
|
|
3
|
+
> 在手机上远程操控 Claude Code —— 随时随地写代码、审批权限、切换模型。
|
|
4
|
+
|
|
5
|
+
## 这是什么?
|
|
6
|
+
|
|
7
|
+
一个轻量级的远程控制桥接器,让你可以通过手机浏览器或 Android App 连接到电脑上运行的 Claude Code,实现:
|
|
8
|
+
|
|
9
|
+
- 💬 远程对话 —— 发消息、看回复、完整的 Markdown 渲染
|
|
10
|
+
- 🔧 工具调用可视化 —— 实时查看 Claude 正在读哪个文件、执行什么命令
|
|
11
|
+
- 🔐 权限审批 —— 手机上一键 Allow / Deny
|
|
12
|
+
- 🔄 模式切换 —— Default / Plan / Accept Edits / Bypass
|
|
13
|
+
- 📱 斜杠命令 —— /model /compact /clear /cost /help
|
|
14
|
+
|
|
15
|
+
## 架构
|
|
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/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**server.js** 是核心桥接层:
|
|
29
|
+
- 通过 `node-pty` 启动并管理 Claude Code CLI 进程
|
|
30
|
+
- 监听 `~/.claude/projects/` 下的 JSONL transcript 文件,实时解析事件
|
|
31
|
+
- 通过 WebSocket 将事件广播给所有连接的客户端
|
|
32
|
+
- 转发客户端输入到 Claude PTY
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
### 1. 启动 Bridge Server
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# 首次运行先安装依赖
|
|
40
|
+
cd /path/to/claudecode_api_RemoteControl
|
|
41
|
+
npm install
|
|
42
|
+
|
|
43
|
+
# 在任意项目目录下,用完整路径启动(默认端口 3100)
|
|
44
|
+
node /path/to/claudecode_api_RemoteControl/server.js
|
|
45
|
+
|
|
46
|
+
# 或指定端口
|
|
47
|
+
PORT=8080 node /path/to/claudecode_api_RemoteControl/server.js
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. 手机浏览器访问
|
|
51
|
+
|
|
52
|
+
确保手机和电脑在同一局域网,浏览器打开:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
http://<电脑IP>:3100
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Android App(可选)
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cd app
|
|
62
|
+
npm install
|
|
63
|
+
npx tauri android init
|
|
64
|
+
npx tauri android dev
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
App 启动后输入 server 地址连接即可。
|
|
68
|
+
|
|
69
|
+
## 项目结构
|
|
70
|
+
|
|
71
|
+
```
|
|
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
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 功能特性
|
|
88
|
+
|
|
89
|
+
| 功能 | 说明 |
|
|
90
|
+
|------|------|
|
|
91
|
+
| 实时对话 | 完整 Markdown + 代码高亮渲染 |
|
|
92
|
+
| 工具调用追踪 | 折叠式 step group,shimmer 加载动效 |
|
|
93
|
+
| 权限队列 | 批量审批,计数器显示剩余数量 |
|
|
94
|
+
| 模式切换 | 四种模式一键切换,彩色状态标识 |
|
|
95
|
+
| 对话压缩 | /compact 带 spinner 遮罩,压缩完自动恢复 |
|
|
96
|
+
| 斜杠命令菜单 | 输入 `/` 弹出命令面板 |
|
|
97
|
+
| 模型切换 | 底部面板选择,toast 即时反馈 |
|
|
98
|
+
| 断线重连 | 自动重连 + 事件回放,不丢消息 |
|
|
99
|
+
| 外部链接 | 点击链接跳转系统浏览器,不影响 WebView |
|
|
100
|
+
| 安卓适配 | 安全区、虚拟键盘、大触摸目标 |
|
|
101
|
+
|
|
102
|
+
## 依赖
|
|
103
|
+
|
|
104
|
+
- **Node.js** >= 18
|
|
105
|
+
- **node-pty** —— PTY 终端模拟
|
|
106
|
+
- **ws** —— WebSocket 服务
|
|
107
|
+
- **Tauri 2.0** —— Android App(可选,需要 Rust 工具链)
|
|
108
|
+
|
|
109
|
+
## TODO
|
|
110
|
+
|
|
111
|
+
> 本项目看到 Claude Code 官方远程控制,可惜不支持api调用的模式,于是空闲时间摸鱼用 vibing code 写的小东西,目前处于快速迭代阶段。
|
|
112
|
+
|
|
113
|
+
- [x] 提问与计划远程弹窗(AskUserQuestion / ExitPlanMode 远程交互)
|
|
114
|
+
- [x] 自动审批命令模式
|
|
115
|
+
- [x] Claude Code ToDo App 渲染
|
|
116
|
+
- [x] 美化部分样式
|
|
117
|
+
- [x] 代码 diff 查看
|
|
118
|
+
- [x] 缓存机制 —— 避免每次 App 重连全量回放
|
|
119
|
+
- [x] App 支持图片上传
|
|
120
|
+
- [x] /命令指令及样式美化
|
|
121
|
+
- [ ] Tool Use 状态渲染(进行中 / 成功 / 失败)
|
|
122
|
+
- [ ] npm 包化 —— 改造为 npm install 全局安装,支持 npx 一键启动
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bridge approval hook — routes PreToolUse permission requests to WebUI.
|
|
3
|
+
// If bridge server is unreachable or no WebUI clients, falls back to
|
|
4
|
+
// normal terminal prompt (decision: "ask").
|
|
5
|
+
|
|
6
|
+
const http = require('http');
|
|
7
|
+
|
|
8
|
+
// Only route to WebUI when spawned by bridge server (which sets BRIDGE_PORT).
|
|
9
|
+
// Native Claude instances fall back to normal terminal prompt.
|
|
10
|
+
if (!process.env.BRIDGE_PORT) process.exit(0);
|
|
11
|
+
|
|
12
|
+
const PORT = process.env.BRIDGE_PORT;
|
|
13
|
+
|
|
14
|
+
let input = '';
|
|
15
|
+
process.stdin.setEncoding('utf8');
|
|
16
|
+
process.stdin.on('data', chunk => (input += chunk));
|
|
17
|
+
process.stdin.on('end', () => {
|
|
18
|
+
let data;
|
|
19
|
+
try { data = JSON.parse(input); } catch { process.exit(0); }
|
|
20
|
+
|
|
21
|
+
// Auto-allow AskUserQuestion, ExitPlanMode, EnterPlanMode — handled via PTY interaction in remote UI
|
|
22
|
+
if (data.tool_name === 'AskUserQuestion' || data.tool_name === 'ExitPlanMode' || data.tool_name === 'EnterPlanMode') {
|
|
23
|
+
process.stdout.write(JSON.stringify({
|
|
24
|
+
hookSpecificOutput: {
|
|
25
|
+
hookEventName: 'PreToolUse',
|
|
26
|
+
permissionDecision: 'allow',
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const body = JSON.stringify(data);
|
|
33
|
+
const req = http.request({
|
|
34
|
+
hostname: '127.0.0.1',
|
|
35
|
+
port: PORT,
|
|
36
|
+
path: '/hook/pre-tool-use',
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
'Content-Length': Buffer.byteLength(body),
|
|
41
|
+
},
|
|
42
|
+
}, res => {
|
|
43
|
+
let resBody = '';
|
|
44
|
+
res.on('data', d => (resBody += d));
|
|
45
|
+
res.on('end', () => {
|
|
46
|
+
try {
|
|
47
|
+
const result = JSON.parse(resBody);
|
|
48
|
+
const decision = result.decision || 'ask';
|
|
49
|
+
const reason = result.reason || '';
|
|
50
|
+
const out = {
|
|
51
|
+
hookSpecificOutput: {
|
|
52
|
+
hookEventName: 'PreToolUse',
|
|
53
|
+
permissionDecision: decision,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
if (reason && decision === 'deny') {
|
|
57
|
+
out.hookSpecificOutput.permissionDecisionReason = reason;
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write(JSON.stringify(out));
|
|
60
|
+
} catch {}
|
|
61
|
+
process.exit(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
req.on('error', () => process.exit(0)); // bridge offline → ask
|
|
66
|
+
req.setTimeout(120000, () => { req.destroy(); process.exit(0); });
|
|
67
|
+
req.write(body);
|
|
68
|
+
req.end();
|
|
69
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bridge stop hook — notifies WebUI that Claude's turn has ended.
|
|
3
|
+
// Fire-and-forget: POST stdin JSON to bridge server, don't wait for response.
|
|
4
|
+
|
|
5
|
+
const http = require('http');
|
|
6
|
+
|
|
7
|
+
if (!process.env.BRIDGE_PORT) process.exit(0);
|
|
8
|
+
|
|
9
|
+
const PORT = process.env.BRIDGE_PORT;
|
|
10
|
+
|
|
11
|
+
let input = '';
|
|
12
|
+
process.stdin.setEncoding('utf8');
|
|
13
|
+
process.stdin.on('data', chunk => (input += chunk));
|
|
14
|
+
process.stdin.on('end', () => {
|
|
15
|
+
let data;
|
|
16
|
+
try { data = JSON.parse(input); } catch { process.exit(0); }
|
|
17
|
+
|
|
18
|
+
const body = JSON.stringify(data);
|
|
19
|
+
const req = http.request({
|
|
20
|
+
hostname: '127.0.0.1',
|
|
21
|
+
port: PORT,
|
|
22
|
+
path: '/hook/stop',
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'Content-Length': Buffer.byteLength(body),
|
|
27
|
+
},
|
|
28
|
+
}, () => {
|
|
29
|
+
// Fire-and-forget — don't care about response
|
|
30
|
+
process.exit(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
req.on('error', () => process.exit(0));
|
|
34
|
+
req.setTimeout(10000, () => { req.destroy(); process.exit(0); });
|
|
35
|
+
req.write(body);
|
|
36
|
+
req.end();
|
|
37
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-remote",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Remote control bridge for Claude Code REPL - drive from phone/WebUI",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-remote": "bin/claude-remote.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"server.js",
|
|
11
|
+
"hooks/",
|
|
12
|
+
"bin/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node server.js"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"node-pty": "^1.0.0",
|
|
19
|
+
"ws": "^8.18.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const pty = require('node-pty');
|
|
6
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
7
|
+
const { execSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
// --- Config ---
|
|
10
|
+
const PORT = parseInt(process.env.PORT || '3100', 10);
|
|
11
|
+
const CWD = process.argv[2] || process.cwd();
|
|
12
|
+
const CLAUDE_HOME = path.join(os.homedir(), '.claude');
|
|
13
|
+
const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
|
|
14
|
+
|
|
15
|
+
// --- State ---
|
|
16
|
+
let claudeProc = null;
|
|
17
|
+
let transcriptPath = null;
|
|
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);
|
|
31
|
+
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
32
|
+
const LEGACY_REPLAY_DELAY_MS = 1500;
|
|
33
|
+
const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
// --- Permission approval state ---
|
|
36
|
+
let approvalSeq = 0;
|
|
37
|
+
const pendingApprovals = new Map(); // id → { res, timer }
|
|
38
|
+
const pendingImageUploads = new Map();
|
|
39
|
+
let currentMode = 'default';
|
|
40
|
+
let approvalMode = 'default'; // 'default' | 'partial' | 'all'
|
|
41
|
+
const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
|
|
42
|
+
const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
|
|
43
|
+
|
|
44
|
+
// --- Logging → file only (never pollute the terminal) ---
|
|
45
|
+
const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
|
|
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
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============================================================
|
|
53
|
+
// 1. Static file server
|
|
54
|
+
// ============================================================
|
|
55
|
+
const MIME = {
|
|
56
|
+
'.html': 'text/html; charset=utf-8',
|
|
57
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
58
|
+
'.css': 'text/css; charset=utf-8',
|
|
59
|
+
'.json': 'application/json',
|
|
60
|
+
'.png': 'image/png',
|
|
61
|
+
'.svg': 'image/svg+xml',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const server = http.createServer((req, res) => {
|
|
65
|
+
const url = req.url.split('?')[0];
|
|
66
|
+
|
|
67
|
+
// --- API: Hook approval endpoint ---
|
|
68
|
+
if (req.method === 'POST' && url === '/hook/pre-tool-use') {
|
|
69
|
+
let body = '';
|
|
70
|
+
req.on('data', chunk => (body += chunk));
|
|
71
|
+
req.on('end', () => {
|
|
72
|
+
let data;
|
|
73
|
+
try { data = JSON.parse(body); } catch {
|
|
74
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
75
|
+
res.end(JSON.stringify({ decision: 'ask' }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
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)) {
|
|
86
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
87
|
+
res.end(JSON.stringify({ decision: 'allow' }));
|
|
88
|
+
log(`Permission auto-allowed (always): ${data.tool_name}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Auto-approve based on approvalMode setting
|
|
93
|
+
if (approvalMode === 'all') {
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
95
|
+
res.end(JSON.stringify({ decision: 'allow' }));
|
|
96
|
+
log(`Permission auto-allowed (mode=all): ${data.tool_name}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (approvalMode === 'partial' && PARTIAL_AUTO_ALLOW.has(data.tool_name)) {
|
|
100
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
101
|
+
res.end(JSON.stringify({ decision: 'allow' }));
|
|
102
|
+
log(`Permission auto-allowed (mode=partial): ${data.tool_name}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No WebUI clients → fall back to terminal prompt
|
|
107
|
+
const clients = [...wss.clients].filter(c => c.readyState === WebSocket.OPEN);
|
|
108
|
+
if (clients.length === 0) {
|
|
109
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
110
|
+
res.end(JSON.stringify({ decision: 'ask' }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const id = String(++approvalSeq);
|
|
115
|
+
log(`Permission #${id}: ${data.tool_name} → ${clients.length} WebUI client(s)`);
|
|
116
|
+
|
|
117
|
+
broadcast({
|
|
118
|
+
type: 'permission_request',
|
|
119
|
+
id,
|
|
120
|
+
toolName: data.tool_name,
|
|
121
|
+
toolInput: data.tool_input,
|
|
122
|
+
permissionMode: data.permission_mode,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Hold HTTP response open until WebUI user decides or timeout
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
pendingApprovals.delete(id);
|
|
128
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
129
|
+
res.end(JSON.stringify({ decision: 'ask' }));
|
|
130
|
+
log(`Permission #${id}: timeout → ask`);
|
|
131
|
+
}, 90000);
|
|
132
|
+
|
|
133
|
+
pendingApprovals.set(id, { res, timer });
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- API: Stop hook endpoint ---
|
|
139
|
+
if (req.method === 'POST' && url === '/hook/stop') {
|
|
140
|
+
let body = '';
|
|
141
|
+
req.on('data', chunk => (body += chunk));
|
|
142
|
+
req.on('end', () => {
|
|
143
|
+
log('/hook/stop received — broadcasting turn_complete');
|
|
144
|
+
broadcast({ type: 'turn_complete' });
|
|
145
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
146
|
+
res.end('{}');
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Static files ---
|
|
152
|
+
const filePath = path.join(__dirname, 'web', url === '/' ? 'index.html' : url);
|
|
153
|
+
const ext = path.extname(filePath);
|
|
154
|
+
fs.readFile(filePath, (err, data) => {
|
|
155
|
+
if (err) {
|
|
156
|
+
res.writeHead(404);
|
|
157
|
+
res.end('Not found');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
161
|
+
res.end(data);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ============================================================
|
|
166
|
+
// 2. WebSocket server
|
|
167
|
+
// ============================================================
|
|
168
|
+
const wss = new WebSocketServer({ server });
|
|
169
|
+
|
|
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
|
+
}
|
|
176
|
+
|
|
177
|
+
function latestEventSeq() {
|
|
178
|
+
return eventBuffer.length > 0 ? eventBuffer[eventBuffer.length - 1].seq : 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
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;
|
|
187
|
+
|
|
188
|
+
for (const record of records) {
|
|
189
|
+
ws.send(JSON.stringify({
|
|
190
|
+
type: 'log_event',
|
|
191
|
+
seq: record.seq,
|
|
192
|
+
event: record.event,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ws.send(JSON.stringify({
|
|
197
|
+
type: 'replay_done',
|
|
198
|
+
sessionId: currentSessionId,
|
|
199
|
+
lastSeq: latestEventSeq(),
|
|
200
|
+
resumed: normalizedLastSeq != null,
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sendUploadStatus(ws, uploadId, status, extra = {}) {
|
|
205
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
206
|
+
ws.send(JSON.stringify({
|
|
207
|
+
type: 'image_upload_status',
|
|
208
|
+
uploadId,
|
|
209
|
+
status,
|
|
210
|
+
...extra,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function cleanupImageUpload(uploadId) {
|
|
215
|
+
const upload = pendingImageUploads.get(uploadId);
|
|
216
|
+
if (!upload) return;
|
|
217
|
+
if (upload.tmpFile) {
|
|
218
|
+
try { fs.unlinkSync(upload.tmpFile); } catch {}
|
|
219
|
+
}
|
|
220
|
+
pendingImageUploads.delete(uploadId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function cleanupClientUploads(ws) {
|
|
224
|
+
for (const [uploadId, upload] of pendingImageUploads) {
|
|
225
|
+
if (upload.owner === ws) cleanupImageUpload(uploadId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function createTempImageFile(buffer, mediaType, uploadId) {
|
|
230
|
+
const tmpDir = process.env.CLAUDE_CODE_TMPDIR || os.tmpdir();
|
|
231
|
+
const type = String(mediaType || 'image/png').toLowerCase();
|
|
232
|
+
const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
|
|
233
|
+
const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
|
|
234
|
+
fs.writeFileSync(tmpFile, buffer);
|
|
235
|
+
return tmpFile;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
setInterval(() => {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
for (const [uploadId, upload] of pendingImageUploads) {
|
|
241
|
+
if ((upload.updatedAt || 0) < (now - IMAGE_UPLOAD_TTL_MS)) {
|
|
242
|
+
cleanupImageUpload(uploadId);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}, 60 * 1000).unref();
|
|
246
|
+
|
|
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
|
+
}
|
|
265
|
+
|
|
266
|
+
// New clients should explicitly request a resume window. Keep a delayed
|
|
267
|
+
// full replay fallback so older clients still work.
|
|
268
|
+
ws._resumeHandled = false;
|
|
269
|
+
ws._legacyReplayTimer = setTimeout(() => {
|
|
270
|
+
if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
|
|
271
|
+
ws._resumeHandled = true;
|
|
272
|
+
sendReplay(ws, null);
|
|
273
|
+
}, LEGACY_REPLAY_DELAY_MS);
|
|
274
|
+
|
|
275
|
+
ws.on('message', (raw) => {
|
|
276
|
+
let msg;
|
|
277
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
278
|
+
|
|
279
|
+
switch (msg.type) {
|
|
280
|
+
case 'resume': {
|
|
281
|
+
ws._resumeHandled = true;
|
|
282
|
+
if (ws._legacyReplayTimer) {
|
|
283
|
+
clearTimeout(ws._legacyReplayTimer);
|
|
284
|
+
ws._legacyReplayTimer = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!currentSessionId) {
|
|
288
|
+
ws.send(JSON.stringify({
|
|
289
|
+
type: 'replay_done',
|
|
290
|
+
sessionId: null,
|
|
291
|
+
lastSeq: 0,
|
|
292
|
+
resumed: false,
|
|
293
|
+
}));
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
|
|
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
|
+
}
|
|
308
|
+
case 'input':
|
|
309
|
+
// Raw terminal keystrokes from xterm.js in WebUI
|
|
310
|
+
if (claudeProc) claudeProc.write(msg.data);
|
|
311
|
+
break;
|
|
312
|
+
case 'expect_clear':
|
|
313
|
+
// Plan mode option 1 triggers /clear inside Claude Code;
|
|
314
|
+
// client notifies us so we can detect the session switch.
|
|
315
|
+
markExpectingSwitch();
|
|
316
|
+
break;
|
|
317
|
+
case 'chat':
|
|
318
|
+
// Chat message from WebUI → write to PTY as user input
|
|
319
|
+
// Must send text first, then Enter after a delay so Claude's
|
|
320
|
+
// TUI (Ink) has time to process the typed characters
|
|
321
|
+
if (claudeProc) {
|
|
322
|
+
const text = msg.text;
|
|
323
|
+
log(`Chat input → PTY: "${text.substring(0, 80)}"`);
|
|
324
|
+
if (/^\/clear\s*$/i.test(text.trim())) {
|
|
325
|
+
markExpectingSwitch();
|
|
326
|
+
}
|
|
327
|
+
claudeProc.write(text);
|
|
328
|
+
setTimeout(() => {
|
|
329
|
+
if (claudeProc) claudeProc.write('\r');
|
|
330
|
+
}, 150);
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
case 'resize':
|
|
334
|
+
// Only resize if no local TTY is controlling size
|
|
335
|
+
if (claudeProc && msg.cols && msg.rows && !isTTY) {
|
|
336
|
+
claudeProc.resize(msg.cols, msg.rows);
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
case 'permission_response': {
|
|
340
|
+
const approval = pendingApprovals.get(msg.id);
|
|
341
|
+
if (approval) {
|
|
342
|
+
clearTimeout(approval.timer);
|
|
343
|
+
pendingApprovals.delete(msg.id);
|
|
344
|
+
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
345
|
+
approval.res.end(JSON.stringify({
|
|
346
|
+
decision: msg.decision,
|
|
347
|
+
reason: msg.reason || '',
|
|
348
|
+
}));
|
|
349
|
+
log(`Permission #${msg.id}: ${msg.decision}`);
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case 'set_approval_mode': {
|
|
354
|
+
const valid = ['default', 'partial', 'all'];
|
|
355
|
+
if (valid.includes(msg.mode)) {
|
|
356
|
+
approvalMode = msg.mode;
|
|
357
|
+
log(`Approval mode changed to: ${approvalMode}`);
|
|
358
|
+
// If switching to 'all' or 'partial', auto-resolve queued permissions
|
|
359
|
+
if (approvalMode === 'all') {
|
|
360
|
+
for (const [id, approval] of pendingApprovals) {
|
|
361
|
+
clearTimeout(approval.timer);
|
|
362
|
+
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
363
|
+
approval.res.end(JSON.stringify({ decision: 'allow' }));
|
|
364
|
+
log(`Permission #${id}: auto-allowed (mode switched to all)`);
|
|
365
|
+
}
|
|
366
|
+
pendingApprovals.clear();
|
|
367
|
+
broadcast({ type: 'clear_permissions' });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case 'image_upload_init': {
|
|
373
|
+
const uploadId = String(msg.uploadId || '');
|
|
374
|
+
if (!uploadId) {
|
|
375
|
+
sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
cleanupImageUpload(uploadId);
|
|
379
|
+
pendingImageUploads.set(uploadId, {
|
|
380
|
+
id: uploadId,
|
|
381
|
+
owner: ws,
|
|
382
|
+
mediaType: msg.mediaType || 'image/png',
|
|
383
|
+
name: msg.name || 'image',
|
|
384
|
+
totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
|
|
385
|
+
totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
|
|
386
|
+
nextChunkIndex: 0,
|
|
387
|
+
receivedBytes: 0,
|
|
388
|
+
chunks: [],
|
|
389
|
+
tmpFile: null,
|
|
390
|
+
updatedAt: Date.now(),
|
|
391
|
+
});
|
|
392
|
+
sendUploadStatus(ws, uploadId, 'ready_for_chunks', { receivedBytes: 0, totalBytes: msg.totalBytes || 0 });
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case 'image_upload_chunk': {
|
|
396
|
+
const uploadId = String(msg.uploadId || '');
|
|
397
|
+
const upload = pendingImageUploads.get(uploadId);
|
|
398
|
+
if (!upload) {
|
|
399
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
if (upload.owner !== ws) {
|
|
403
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
if (msg.index !== upload.nextChunkIndex) {
|
|
407
|
+
sendUploadStatus(ws, uploadId, 'error', {
|
|
408
|
+
message: `Unexpected chunk index ${msg.index}, expected ${upload.nextChunkIndex}`,
|
|
409
|
+
});
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
if (!msg.base64) {
|
|
413
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Missing chunk payload' });
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const chunk = Buffer.from(msg.base64, 'base64');
|
|
419
|
+
upload.chunks.push(chunk);
|
|
420
|
+
upload.receivedBytes += chunk.length;
|
|
421
|
+
upload.nextChunkIndex += 1;
|
|
422
|
+
upload.updatedAt = Date.now();
|
|
423
|
+
sendUploadStatus(ws, uploadId, 'uploading', {
|
|
424
|
+
chunkIndex: msg.index,
|
|
425
|
+
receivedBytes: upload.receivedBytes,
|
|
426
|
+
totalBytes: upload.totalBytes,
|
|
427
|
+
});
|
|
428
|
+
} catch (err) {
|
|
429
|
+
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
430
|
+
}
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
case 'image_upload_complete': {
|
|
434
|
+
const uploadId = String(msg.uploadId || '');
|
|
435
|
+
const upload = pendingImageUploads.get(uploadId);
|
|
436
|
+
if (!upload) {
|
|
437
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
if (upload.owner !== ws) {
|
|
441
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
if (upload.nextChunkIndex !== upload.totalChunks) {
|
|
445
|
+
sendUploadStatus(ws, uploadId, 'error', {
|
|
446
|
+
message: `Upload incomplete (${upload.nextChunkIndex}/${upload.totalChunks})`,
|
|
447
|
+
});
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const buffer = Buffer.concat(upload.chunks);
|
|
453
|
+
upload.tmpFile = createTempImageFile(buffer, upload.mediaType, uploadId);
|
|
454
|
+
upload.chunks = [];
|
|
455
|
+
upload.updatedAt = Date.now();
|
|
456
|
+
log(`Image pre-upload complete: ${upload.tmpFile} (${buffer.length} bytes)`);
|
|
457
|
+
sendUploadStatus(ws, uploadId, 'uploaded', {
|
|
458
|
+
receivedBytes: upload.receivedBytes,
|
|
459
|
+
totalBytes: upload.totalBytes,
|
|
460
|
+
});
|
|
461
|
+
} catch (err) {
|
|
462
|
+
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
463
|
+
cleanupImageUpload(uploadId);
|
|
464
|
+
}
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
case 'image_upload_abort': {
|
|
468
|
+
const uploadId = String(msg.uploadId || '');
|
|
469
|
+
if (uploadId) cleanupImageUpload(uploadId);
|
|
470
|
+
sendUploadStatus(ws, uploadId, 'aborted');
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
case 'image_submit': {
|
|
474
|
+
const uploadId = String(msg.uploadId || '');
|
|
475
|
+
const upload = pendingImageUploads.get(uploadId);
|
|
476
|
+
if (!upload || !upload.tmpFile) {
|
|
477
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
handlePreparedImageUpload({
|
|
482
|
+
tmpFile: upload.tmpFile,
|
|
483
|
+
mediaType: upload.mediaType,
|
|
484
|
+
text: msg.text || '',
|
|
485
|
+
logLabel: upload.name || uploadId,
|
|
486
|
+
onCleanup: () => cleanupImageUpload(uploadId),
|
|
487
|
+
});
|
|
488
|
+
sendUploadStatus(ws, uploadId, 'submitted');
|
|
489
|
+
} catch (err) {
|
|
490
|
+
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
491
|
+
cleanupImageUpload(uploadId);
|
|
492
|
+
}
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case 'image_upload': {
|
|
496
|
+
handleImageUpload(msg);
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
ws.on('close', () => {
|
|
503
|
+
if (ws._legacyReplayTimer) {
|
|
504
|
+
clearTimeout(ws._legacyReplayTimer);
|
|
505
|
+
ws._legacyReplayTimer = null;
|
|
506
|
+
}
|
|
507
|
+
cleanupClientUploads(ws);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
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
|
+
// ============================================================
|
|
590
|
+
// 4. PTY Manager — local terminal passthrough
|
|
591
|
+
// ============================================================
|
|
592
|
+
function spawnClaude() {
|
|
593
|
+
snapshotExistingFiles();
|
|
594
|
+
|
|
595
|
+
const isWin = process.platform === 'win32';
|
|
596
|
+
const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
|
|
597
|
+
const args = isWin
|
|
598
|
+
? ['-NoLogo', '-NoProfile', '-Command', 'claude']
|
|
599
|
+
: ['-c', 'claude'];
|
|
600
|
+
|
|
601
|
+
// Use local terminal size if available, otherwise default
|
|
602
|
+
const cols = isTTY ? process.stdout.columns : 120;
|
|
603
|
+
const rows = isTTY ? process.stdout.rows : 40;
|
|
604
|
+
|
|
605
|
+
claudeProc = pty.spawn(shell, args, {
|
|
606
|
+
name: 'xterm-256color',
|
|
607
|
+
cols,
|
|
608
|
+
rows,
|
|
609
|
+
cwd: CWD,
|
|
610
|
+
env: { ...process.env, FORCE_COLOR: '1', BRIDGE_PORT: String(PORT) },
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows}`);
|
|
614
|
+
broadcast({ type: 'status', status: 'running', pid: claudeProc.pid });
|
|
615
|
+
|
|
616
|
+
// === PTY output → local terminal + WebSocket + mode detection ===
|
|
617
|
+
claudeProc.onData((data) => {
|
|
618
|
+
if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
|
|
619
|
+
broadcast({ type: 'pty_output', data }); // push to WebUI
|
|
620
|
+
detectModeFromPTY(data);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// === Local terminal input → PTY ===
|
|
624
|
+
if (isTTY) {
|
|
625
|
+
process.stdin.setRawMode(true);
|
|
626
|
+
process.stdin.resume();
|
|
627
|
+
process.stdin.on('data', (chunk) => {
|
|
628
|
+
if (claudeProc) claudeProc.write(chunk);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// Resize PTY when local terminal resizes
|
|
632
|
+
process.stdout.on('resize', () => {
|
|
633
|
+
if (claudeProc) {
|
|
634
|
+
claudeProc.resize(process.stdout.columns, process.stdout.rows);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// === PTY exit → cleanup ===
|
|
640
|
+
claudeProc.onExit(({ exitCode, signal }) => {
|
|
641
|
+
log(`Claude exited (code=${exitCode}, signal=${signal})`);
|
|
642
|
+
broadcast({ type: 'pty_exit', exitCode, signal });
|
|
643
|
+
claudeProc = null;
|
|
644
|
+
|
|
645
|
+
// Restore terminal and exit bridge
|
|
646
|
+
if (isTTY) {
|
|
647
|
+
process.stdin.setRawMode(false);
|
|
648
|
+
process.stdin.pause();
|
|
649
|
+
}
|
|
650
|
+
stopTailing();
|
|
651
|
+
log('Bridge shutting down.');
|
|
652
|
+
setTimeout(() => process.exit(exitCode || 0), 300);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ============================================================
|
|
657
|
+
// 4. Transcript Discovery & Tailing
|
|
658
|
+
// ============================================================
|
|
659
|
+
function getProjectSlug(cwd) {
|
|
660
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function hasConversationEvent(evt) {
|
|
664
|
+
if (!evt || typeof evt !== 'object') return false;
|
|
665
|
+
if (evt.type === 'user' || evt.type === 'assistant') return true;
|
|
666
|
+
const role = evt.message && typeof evt.message === 'object' ? evt.message.role : null;
|
|
667
|
+
return role === 'user' || role === 'assistant';
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function fileLooksLikeTranscript(filePath) {
|
|
671
|
+
try {
|
|
672
|
+
const stat = fs.statSync(filePath);
|
|
673
|
+
if (stat.size <= 0) return false;
|
|
674
|
+
|
|
675
|
+
const readSize = Math.min(stat.size, 64 * 1024);
|
|
676
|
+
const fd = fs.openSync(filePath, 'r');
|
|
677
|
+
const buf = Buffer.alloc(readSize);
|
|
678
|
+
fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
|
|
679
|
+
fs.closeSync(fd);
|
|
680
|
+
|
|
681
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
682
|
+
for (const line of lines) {
|
|
683
|
+
try {
|
|
684
|
+
const evt = JSON.parse(line);
|
|
685
|
+
if (hasConversationEvent(evt)) return true;
|
|
686
|
+
} catch {
|
|
687
|
+
// ignore malformed lines at file tail
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
} catch {}
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function attachTranscript(target, startOffset = 0) {
|
|
695
|
+
transcriptPath = target.full;
|
|
696
|
+
currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
697
|
+
transcriptOffset = Math.max(0, startOffset);
|
|
698
|
+
tailRemainder = Buffer.alloc(0);
|
|
699
|
+
eventBuffer = [];
|
|
700
|
+
eventSeq = 0;
|
|
701
|
+
|
|
702
|
+
log(`Transcript attached: ${currentSessionId} (offset=${transcriptOffset})`);
|
|
703
|
+
broadcast({
|
|
704
|
+
type: 'transcript_ready',
|
|
705
|
+
transcript: transcriptPath,
|
|
706
|
+
sessionId: currentSessionId,
|
|
707
|
+
lastSeq: 0,
|
|
708
|
+
});
|
|
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
|
+
}
|
|
785
|
+
|
|
786
|
+
function markExpectingSwitch() {
|
|
787
|
+
expectingSwitch = true;
|
|
788
|
+
if (expectingSwitchTimer) clearTimeout(expectingSwitchTimer);
|
|
789
|
+
expectingSwitchTimer = setTimeout(() => {
|
|
790
|
+
expectingSwitch = false;
|
|
791
|
+
expectingSwitchTimer = null;
|
|
792
|
+
log('Expecting-switch flag expired (no new transcript found)');
|
|
793
|
+
}, 15000);
|
|
794
|
+
log('Expecting session switch (/clear detected)');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function startSwitchWatcher() {
|
|
798
|
+
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
799
|
+
const slug = getProjectSlug(CWD);
|
|
800
|
+
const projectDir = path.join(PROJECTS_DIR, slug);
|
|
801
|
+
|
|
802
|
+
switchWatcher = setInterval(() => {
|
|
803
|
+
if (!transcriptPath || !expectingSwitch || !fs.existsSync(projectDir)) return;
|
|
804
|
+
try {
|
|
805
|
+
const currentBasename = path.basename(transcriptPath);
|
|
806
|
+
const candidates = fs.readdirSync(projectDir)
|
|
807
|
+
.filter(f => f.endsWith('.jsonl') && f !== currentBasename)
|
|
808
|
+
.map(f => {
|
|
809
|
+
const full = path.join(projectDir, f);
|
|
810
|
+
const stat = fs.statSync(full);
|
|
811
|
+
return { name: f, full, mtime: stat.mtimeMs, size: stat.size };
|
|
812
|
+
})
|
|
813
|
+
.filter(t => t.mtime > fs.statSync(transcriptPath).mtimeMs)
|
|
814
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
815
|
+
|
|
816
|
+
const newer = candidates.find(t => fileLooksLikeTranscript(t.full));
|
|
817
|
+
if (newer) {
|
|
818
|
+
log(`Session switch detected → ${path.basename(newer.full, '.jsonl')}`);
|
|
819
|
+
expectingSwitch = false;
|
|
820
|
+
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
821
|
+
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
822
|
+
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
823
|
+
attachTranscript(newer, 0);
|
|
824
|
+
}
|
|
825
|
+
} catch {}
|
|
826
|
+
}, 500);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function startTailing() {
|
|
830
|
+
tailRemainder = Buffer.alloc(0);
|
|
831
|
+
tailTimer = setInterval(() => {
|
|
832
|
+
if (!transcriptPath) return;
|
|
833
|
+
try {
|
|
834
|
+
const stat = fs.statSync(transcriptPath);
|
|
835
|
+
if (stat.size <= transcriptOffset) return;
|
|
836
|
+
|
|
837
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
838
|
+
const buf = Buffer.alloc(stat.size - transcriptOffset);
|
|
839
|
+
fs.readSync(fd, buf, 0, buf.length, transcriptOffset);
|
|
840
|
+
fs.closeSync(fd);
|
|
841
|
+
transcriptOffset = stat.size;
|
|
842
|
+
|
|
843
|
+
const data = tailRemainder.length > 0 ? Buffer.concat([tailRemainder, buf]) : buf;
|
|
844
|
+
let start = 0;
|
|
845
|
+
for (let i = 0; i < data.length; i++) {
|
|
846
|
+
if (data[i] !== 0x0A) continue; // '\n'
|
|
847
|
+
const line = data.slice(start, i).toString('utf8').trim();
|
|
848
|
+
start = i + 1;
|
|
849
|
+
if (!line) continue;
|
|
850
|
+
try {
|
|
851
|
+
const event = JSON.parse(line);
|
|
852
|
+
// Detect /clear from JSONL events (covers terminal direct input)
|
|
853
|
+
if (event.type === 'user' || (event.message && event.message.role === 'user')) {
|
|
854
|
+
const content = event.message && event.message.content;
|
|
855
|
+
if (typeof content === 'string' && /^\/clear\s*$/i.test(content.trim())) {
|
|
856
|
+
markExpectingSwitch();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const record = { seq: ++eventSeq, event };
|
|
860
|
+
eventBuffer.push(record);
|
|
861
|
+
if (eventBuffer.length > EVENT_BUFFER_MAX) {
|
|
862
|
+
eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
|
|
863
|
+
}
|
|
864
|
+
broadcast({ type: 'log_event', seq: record.seq, event });
|
|
865
|
+
} catch {
|
|
866
|
+
// skip malformed lines
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
tailRemainder = data.slice(start);
|
|
870
|
+
} catch {
|
|
871
|
+
// file might be temporarily locked
|
|
872
|
+
}
|
|
873
|
+
}, 300);
|
|
874
|
+
}
|
|
875
|
+
|
|
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;
|
|
882
|
+
tailRemainder = Buffer.alloc(0);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ============================================================
|
|
886
|
+
// 5. Image Upload → Clipboard Injection
|
|
887
|
+
// ============================================================
|
|
888
|
+
function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
|
|
889
|
+
if (!claudeProc) throw new Error('Claude not running');
|
|
890
|
+
if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
|
|
891
|
+
|
|
892
|
+
const isWin = process.platform === 'win32';
|
|
893
|
+
const isMac = process.platform === 'darwin';
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
const stat = fs.statSync(tmpFile);
|
|
897
|
+
log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
|
|
898
|
+
|
|
899
|
+
if (isWin) {
|
|
900
|
+
const psCmd = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $img = [System.Drawing.Image]::FromFile('${tmpFile.replace(/'/g, "''")}'); [System.Windows.Forms.Clipboard]::SetImage($img); $img.Dispose()`;
|
|
901
|
+
execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
|
|
902
|
+
} else if (isMac) {
|
|
903
|
+
execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
|
|
904
|
+
} else {
|
|
905
|
+
try {
|
|
906
|
+
execSync(`xclip -selection clipboard -t image/png -i < "${tmpFile}"`, { timeout: 10000, shell: true });
|
|
907
|
+
} catch {
|
|
908
|
+
execSync(`wl-copy --type image/png < "${tmpFile}"`, { timeout: 10000, shell: true });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
log('Clipboard set with image');
|
|
912
|
+
|
|
913
|
+
if (isWin) claudeProc.write('\x1bv');
|
|
914
|
+
else claudeProc.write('\x16');
|
|
915
|
+
log('Sent image paste keypress to PTY');
|
|
916
|
+
|
|
917
|
+
setTimeout(() => {
|
|
918
|
+
if (!claudeProc) return;
|
|
919
|
+
const trimmedText = (text || '').trim();
|
|
920
|
+
if (trimmedText) claudeProc.write(trimmedText);
|
|
921
|
+
|
|
922
|
+
setTimeout(() => {
|
|
923
|
+
if (claudeProc) claudeProc.write('\r');
|
|
924
|
+
log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
|
|
925
|
+
|
|
926
|
+
setTimeout(() => {
|
|
927
|
+
if (onCleanup) onCleanup();
|
|
928
|
+
else {
|
|
929
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
930
|
+
}
|
|
931
|
+
}, 5000);
|
|
932
|
+
}, 150);
|
|
933
|
+
}, 1000);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
log(`Image upload error: ${err.message}`);
|
|
936
|
+
if (onCleanup) onCleanup();
|
|
937
|
+
else {
|
|
938
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
939
|
+
}
|
|
940
|
+
throw err;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function handleImageUpload(msg) {
|
|
945
|
+
if (!claudeProc) {
|
|
946
|
+
log('Image upload ignored: Claude not running');
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (!msg.base64) {
|
|
950
|
+
log('Image upload ignored: no base64 data');
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const buf = Buffer.from(msg.base64, 'base64');
|
|
955
|
+
const tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
|
|
956
|
+
|
|
957
|
+
try {
|
|
958
|
+
log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
|
|
959
|
+
handlePreparedImageUpload({
|
|
960
|
+
tmpFile,
|
|
961
|
+
mediaType: msg.mediaType,
|
|
962
|
+
text: msg.text || '',
|
|
963
|
+
});
|
|
964
|
+
} catch (err) {
|
|
965
|
+
log(`Image upload error: ${err.message}`);
|
|
966
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ============================================================
|
|
971
|
+
// 6. Hook Auto-Setup
|
|
972
|
+
|
|
973
|
+
// ============================================================
|
|
974
|
+
function setupHooks() {
|
|
975
|
+
const claudeDir = path.join(CWD, '.claude');
|
|
976
|
+
if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
|
|
977
|
+
|
|
978
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
979
|
+
let settings = {};
|
|
980
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
981
|
+
|
|
982
|
+
const hookScript = path.resolve(__dirname, 'hooks', 'bridge-approval.js').replace(/\\/g, '/');
|
|
983
|
+
const hookCmd = `node "${hookScript}"`;
|
|
984
|
+
|
|
985
|
+
// Merge bridge hook into PreToolUse (preserve user's other hooks)
|
|
986
|
+
const existing = settings.hooks?.PreToolUse || [];
|
|
987
|
+
const bridgeIdx = existing.findIndex(e =>
|
|
988
|
+
e.hooks?.some(h => h.command?.includes('bridge-approval'))
|
|
989
|
+
);
|
|
990
|
+
const bridgeEntry = {
|
|
991
|
+
matcher: '',
|
|
992
|
+
hooks: [{ type: 'command', command: hookCmd, timeout: 120 }],
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
if (bridgeIdx >= 0) {
|
|
996
|
+
existing[bridgeIdx] = bridgeEntry;
|
|
997
|
+
} else {
|
|
998
|
+
existing.push(bridgeEntry);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
settings.hooks = settings.hooks || {};
|
|
1002
|
+
settings.hooks.PreToolUse = existing;
|
|
1003
|
+
|
|
1004
|
+
// Merge bridge hook into Stop (notify WebUI when Claude's turn ends)
|
|
1005
|
+
const stopScript = path.resolve(__dirname, 'hooks', 'bridge-stop.js').replace(/\\/g, '/');
|
|
1006
|
+
const stopCmd = `node "${stopScript}"`;
|
|
1007
|
+
const existingStop = settings.hooks.Stop || [];
|
|
1008
|
+
const stopBridgeIdx = existingStop.findIndex(e =>
|
|
1009
|
+
e.hooks?.some(h => h.command?.includes('bridge-stop'))
|
|
1010
|
+
);
|
|
1011
|
+
const stopEntry = {
|
|
1012
|
+
hooks: [{ type: 'command', command: stopCmd, timeout: 10 }],
|
|
1013
|
+
};
|
|
1014
|
+
if (stopBridgeIdx >= 0) {
|
|
1015
|
+
existingStop[stopBridgeIdx] = stopEntry;
|
|
1016
|
+
} else {
|
|
1017
|
+
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
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ============================================================
|
|
1026
|
+
// 7. Startup
|
|
1027
|
+
// ============================================================
|
|
1028
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
1029
|
+
const ifaces = os.networkInterfaces();
|
|
1030
|
+
let lanIp = 'localhost';
|
|
1031
|
+
for (const name of Object.keys(ifaces)) {
|
|
1032
|
+
for (const iface of ifaces[name]) {
|
|
1033
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
1034
|
+
lanIp = iface.address;
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const local = `http://localhost:${PORT}`;
|
|
1040
|
+
const lan = `http://${lanIp}:${PORT}`;
|
|
1041
|
+
|
|
1042
|
+
// Print banner to stdout BEFORE PTY takes over
|
|
1043
|
+
process.stdout.write(`
|
|
1044
|
+
Claude Remote Control Bridge
|
|
1045
|
+
─────────────────────────────
|
|
1046
|
+
Local: ${local}
|
|
1047
|
+
LAN: ${lan}
|
|
1048
|
+
CWD: ${CWD}
|
|
1049
|
+
Log: ${LOG_FILE}
|
|
1050
|
+
|
|
1051
|
+
Phone: ${lan}
|
|
1052
|
+
─────────────────────────────
|
|
1053
|
+
|
|
1054
|
+
`);
|
|
1055
|
+
setupHooks();
|
|
1056
|
+
spawnClaude();
|
|
1057
|
+
startDiscovery();
|
|
1058
|
+
});
|