remote-claude 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/.env.example +15 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/bin/cl +20 -0
- package/bin/cla +20 -0
- package/bin/remote-claude +21 -0
- package/client/client.py +251 -0
- package/lark_client/__init__.py +3 -0
- package/lark_client/capture_output.py +91 -0
- package/lark_client/card_builder.py +1114 -0
- package/lark_client/card_service.py +250 -0
- package/lark_client/config.py +22 -0
- package/lark_client/lark_handler.py +841 -0
- package/lark_client/main.py +306 -0
- package/lark_client/output_cleaner.py +222 -0
- package/lark_client/session_bridge.py +195 -0
- package/lark_client/shared_memory_poller.py +364 -0
- package/lark_client/terminal_buffer.py +215 -0
- package/lark_client/terminal_renderer.py +69 -0
- package/package.json +41 -0
- package/pyproject.toml +14 -0
- package/remote_claude.py +518 -0
- package/scripts/check-env.sh +40 -0
- package/scripts/completion.sh +76 -0
- package/scripts/postinstall.sh +76 -0
- package/server/component_parser.py +1113 -0
- package/server/rich_text_renderer.py +301 -0
- package/server/server.py +801 -0
- package/server/shared_state.py +198 -0
- package/stats/__init__.py +38 -0
- package/stats/collector.py +325 -0
- package/stats/machine.py +47 -0
- package/stats/query.py +151 -0
- package/utils/components.py +165 -0
- package/utils/protocol.py +164 -0
- package/utils/session.py +409 -0
- package/uv.lock +703 -0
package/.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Remote Claude 飞书客户端配置
|
|
2
|
+
|
|
3
|
+
# 飞书应用配置(必填)
|
|
4
|
+
# 在飞书开发者后台创建应用获取: https://open.feishu.cn/app
|
|
5
|
+
FEISHU_APP_ID=cli_xxxxx
|
|
6
|
+
FEISHU_APP_SECRET=xxxxx
|
|
7
|
+
|
|
8
|
+
# 用户白名单(可选)
|
|
9
|
+
# 启用后只有白名单中的用户可以使用
|
|
10
|
+
ENABLE_USER_WHITELIST=false
|
|
11
|
+
ALLOWED_USERS=ou_xxxxx,ou_yyyyy
|
|
12
|
+
|
|
13
|
+
# 机器人名称(用于群聊命名,默认 Claude)
|
|
14
|
+
BOT_NAME=Ys-Claude
|
|
15
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Remote Claude Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Remote Claude
|
|
2
|
+
|
|
3
|
+
**在电脑终端上打开的 Claude Code 进程,也可以在飞书中共享操作。电脑端、手机端无缝来回切换**
|
|
4
|
+
|
|
5
|
+
电脑上用终端跑 Claude Code 写代码,同时在手机飞书上看进度、发指令、点按钮 — 不用守在电脑前,随时随地掌控 AI 编程。
|
|
6
|
+
|
|
7
|
+
## 为什么需要它?
|
|
8
|
+
|
|
9
|
+
Claude Code 只能在启动它的那个终端窗口里操作。一旦离开电脑,就只能干等。Remote Claude 让你:
|
|
10
|
+
|
|
11
|
+
- **飞书里直接操作** — 手机/平板打开飞书,就能看到 Claude 的实时输出,发消息、选选项、批准权限,和终端里一模一样。
|
|
12
|
+
- **用手机无缝延续电脑上做的工作** — 电脑上打开的Claude进程,也可以用飞书共享操作,开会、午休、通勤、上厕所时,都可以用手机延续之前在电脑上的工作。
|
|
13
|
+
- **在电脑上也可以无缝延续手机上的工作** - 在lark端也可以打开新的Claude进程启动新的工作,回到电脑前还可以`attach`共享操作同一个Claude进程,延续手机端的工作。
|
|
14
|
+
- **多端共享操作** — 多个终端 + 飞书可以共享操作同一个claude进程,回到家里ssh登录到服务器上也可以通过`attach`继续操作在公司ssh登录到服务器上打开的claude进程操作。
|
|
15
|
+
- **机制安全** - 完全不侵入 Claude 进程,remote 功能完全通过终端交互来实现,不必担心 Claude 进程意外崩溃导致工作进展丢失。
|
|
16
|
+
|
|
17
|
+
## 飞书端体验
|
|
18
|
+
|
|
19
|
+
- 彩色代码输出,ANSI 着色完整还原
|
|
20
|
+
- 交互式按钮:选项选择、权限确认,一键点击
|
|
21
|
+
- 流式卡片更新:Claude 边想边输出,飞书端实时滚动显示
|
|
22
|
+
- 后台 agent 状态面板:查看并管理正在运行的子任务
|
|
23
|
+
|
|
24
|
+
## 快速开始
|
|
25
|
+
|
|
26
|
+
### 1. 克隆并初始化
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/yyzybb537/remote_claude.git
|
|
30
|
+
cd remote_claude
|
|
31
|
+
./init.sh
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`init.sh` 会自动安装 uv、tmux 等依赖,配置飞书环境(可选),并写入 `cla` / `cl` 快捷命令。执行完成后重启终端生效。
|
|
35
|
+
|
|
36
|
+
### 2. 启动
|
|
37
|
+
|
|
38
|
+
| 快捷命令 | 说明 |
|
|
39
|
+
|------|------|
|
|
40
|
+
| `cla` | 启动 Claude (以当前目录路径为会话名) |
|
|
41
|
+
| `cl` | 同 `cla`,但跳过权限确认 |
|
|
42
|
+
|
|
43
|
+
### 3. 从其他终端连接(一般不需要)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
remote-claude list
|
|
47
|
+
remote-claude attach <会话名>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 4. 从飞书端连接
|
|
51
|
+
|
|
52
|
+
#### 4.1 配置飞书机器人
|
|
53
|
+
|
|
54
|
+
1. 登录[飞书开放平台](https://open.feishu.cn/),创建企业自建应用
|
|
55
|
+
2. 执行命令`cp .env.example .env`, 获取 **App ID** 和 **App Secret**,填入 `.env` 文件
|
|
56
|
+
3. 用`cla`或`cl`启动一次claude(会附带启动飞书客户端,如果之前已经启动过,需要重启飞书客户端: "remote-claude lark restart" )
|
|
57
|
+
4. 企业自建应用页面`添加应用能力`(机器人能力)
|
|
58
|
+
5. 企业自建应用页面配置事件回调(如果第3步没启动成功这里配置不了):
|
|
59
|
+
- `事件与回调` -> `事件配置` -> `订阅方式`右边的笔图标 -> `选择:使用长连接接收事件` -> `点击保存` -> `下面添加事件: 接收消息 v2.0 (im.message.receive_v1)`
|
|
60
|
+
- `事件与回调` -> `回调配置` -> `订阅方式`右边的笔图标 -> `选择:使用长连接接收回调` -> `点击保存` -> `下面添加回调: 卡片回传交互 (card.action.trigger)`
|
|
61
|
+
6. 企业自建应用页面配置权限:
|
|
62
|
+
- `权限管理` -> `批量导入/导出权限` -> 导入以下内容
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"scopes": {
|
|
66
|
+
"tenant": [
|
|
67
|
+
"base:app:read",
|
|
68
|
+
"base:field:read",
|
|
69
|
+
"base:form:read",
|
|
70
|
+
"base:record:read",
|
|
71
|
+
"base:record:retrieve",
|
|
72
|
+
"base:table:read",
|
|
73
|
+
"board:whiteboard:node:read",
|
|
74
|
+
"calendar:calendar.free_busy:read",
|
|
75
|
+
"cardkit:card:write",
|
|
76
|
+
"contact:contact.base:readonly",
|
|
77
|
+
"contact:user.employee_id:readonly",
|
|
78
|
+
"contact:user.id:readonly",
|
|
79
|
+
"docs:document.comment:read",
|
|
80
|
+
"docs:document.content:read",
|
|
81
|
+
"docs:document.media:download",
|
|
82
|
+
"docs:document.media:upload",
|
|
83
|
+
"docs:document:import",
|
|
84
|
+
"docs:permission.member:auth",
|
|
85
|
+
"docs:permission.member:create",
|
|
86
|
+
"docs:permission.member:transfer",
|
|
87
|
+
"docx:document.block:convert",
|
|
88
|
+
"docx:document:create",
|
|
89
|
+
"docx:document:readonly",
|
|
90
|
+
"docx:document:write_only",
|
|
91
|
+
"drive:drive.metadata:readonly",
|
|
92
|
+
"drive:drive.search:readonly",
|
|
93
|
+
"drive:drive:version:readonly",
|
|
94
|
+
"drive:file:download",
|
|
95
|
+
"drive:file:upload",
|
|
96
|
+
"im:chat.members:read",
|
|
97
|
+
"im:chat.members:write_only",
|
|
98
|
+
"im:chat.tabs:read",
|
|
99
|
+
"im:chat.tabs:write_only",
|
|
100
|
+
"im:chat.top_notice:write_only",
|
|
101
|
+
"im:chat:create",
|
|
102
|
+
"im:chat:delete",
|
|
103
|
+
"im:chat:operate_as_owner",
|
|
104
|
+
"im:chat:read",
|
|
105
|
+
"im:chat:update",
|
|
106
|
+
"im:message.group_at_msg:readonly",
|
|
107
|
+
"im:message.group_msg",
|
|
108
|
+
"im:message.p2p_msg:readonly",
|
|
109
|
+
"im:message.reactions:read",
|
|
110
|
+
"im:message.reactions:write_only",
|
|
111
|
+
"im:message:readonly",
|
|
112
|
+
"im:message:recall",
|
|
113
|
+
"im:message:send_as_bot",
|
|
114
|
+
"im:message:update",
|
|
115
|
+
"im:resource",
|
|
116
|
+
"sheets:spreadsheet.meta:read",
|
|
117
|
+
"sheets:spreadsheet.meta:write_only",
|
|
118
|
+
"sheets:spreadsheet:create",
|
|
119
|
+
"sheets:spreadsheet:read",
|
|
120
|
+
"sheets:spreadsheet:write_only",
|
|
121
|
+
"space:document:delete",
|
|
122
|
+
"space:document:retrieve",
|
|
123
|
+
"wiki:wiki:readonly"
|
|
124
|
+
],
|
|
125
|
+
"user": [
|
|
126
|
+
"base:app:read",
|
|
127
|
+
"base:field:read",
|
|
128
|
+
"base:record:read",
|
|
129
|
+
"base:record:retrieve",
|
|
130
|
+
"base:table:read",
|
|
131
|
+
"calendar:calendar.event:create",
|
|
132
|
+
"calendar:calendar.event:delete",
|
|
133
|
+
"calendar:calendar.event:read",
|
|
134
|
+
"calendar:calendar.event:reply",
|
|
135
|
+
"calendar:calendar.event:update",
|
|
136
|
+
"calendar:calendar.free_busy:read",
|
|
137
|
+
"calendar:calendar:read",
|
|
138
|
+
"cardkit:card:write",
|
|
139
|
+
"contact:user.base:readonly",
|
|
140
|
+
"contact:user.employee_id:readonly",
|
|
141
|
+
"contact:user.id:readonly",
|
|
142
|
+
"docs:document.comment:read",
|
|
143
|
+
"docs:document.content:read",
|
|
144
|
+
"docs:document.media:download",
|
|
145
|
+
"docs:document.media:upload",
|
|
146
|
+
"docx:document.block:convert",
|
|
147
|
+
"docx:document:create",
|
|
148
|
+
"docx:document:readonly",
|
|
149
|
+
"docx:document:write_only",
|
|
150
|
+
"im:chat.managers:write_only",
|
|
151
|
+
"im:chat.members:read",
|
|
152
|
+
"im:chat.members:write_only",
|
|
153
|
+
"im:chat.tabs:read",
|
|
154
|
+
"im:chat.tabs:write_only",
|
|
155
|
+
"im:chat.top_notice:write_only",
|
|
156
|
+
"im:chat:delete",
|
|
157
|
+
"im:chat:read",
|
|
158
|
+
"im:chat:update",
|
|
159
|
+
"im:message.reactions:read",
|
|
160
|
+
"im:message.reactions:write_only",
|
|
161
|
+
"im:message:readonly",
|
|
162
|
+
"im:message:recall",
|
|
163
|
+
"im:message:update",
|
|
164
|
+
"search:docs:read",
|
|
165
|
+
"search:suite_dataset:readonly",
|
|
166
|
+
"sheets:spreadsheet.meta:read",
|
|
167
|
+
"sheets:spreadsheet.meta:write_only",
|
|
168
|
+
"sheets:spreadsheet:create",
|
|
169
|
+
"sheets:spreadsheet:read",
|
|
170
|
+
"sheets:spreadsheet:write_only",
|
|
171
|
+
"space:document:retrieve",
|
|
172
|
+
"task:task:read",
|
|
173
|
+
"task:task:readonly",
|
|
174
|
+
"task:task:write",
|
|
175
|
+
"task:task:writeonly",
|
|
176
|
+
"task:tasklist:read",
|
|
177
|
+
"task:tasklist:writeonly",
|
|
178
|
+
"wiki:wiki:readonly"
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
7. 企业自建应用页面: `创建版本` -> `发布到线上`
|
|
184
|
+
8. 至此,完成飞书机器人配置
|
|
185
|
+
|
|
186
|
+
#### 4.2 通过飞书机器人操作claude
|
|
187
|
+
|
|
188
|
+
1. 从飞书搜素刚刚创建的飞书机器人(第一次搜比较慢,如果搜不到可能是忘记发布了)
|
|
189
|
+
2. 飞书中与机器人对话,可用命令:
|
|
190
|
+
- `/help` 展示可用命令
|
|
191
|
+
- `/menu` 展示菜单卡片,后续操作都操作这个卡片上的按钮即可
|
|
192
|
+
|
|
193
|
+
## 使用指南
|
|
194
|
+
|
|
195
|
+
### 快捷命令
|
|
196
|
+
|
|
197
|
+
| 命令 | 说明 |
|
|
198
|
+
|------|------|
|
|
199
|
+
| `cla` | 启动飞书客户端 + 以当前目录路径为会话名启动 Claude |
|
|
200
|
+
| `cl` | 同 `cla`,但跳过权限确认 |
|
|
201
|
+
|
|
202
|
+
### 终端命令
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
remote-claude start <会话名> # 启动新会话
|
|
206
|
+
remote-claude attach <会话名> # 连接现有会话
|
|
207
|
+
remote-claude list # 查看所有会话
|
|
208
|
+
remote-claude kill <会话名> # 终止会话
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### 飞书客户端
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
remote-claude lark start # 启动(后台运行)
|
|
215
|
+
remote-claude lark stop # 停止
|
|
216
|
+
remote-claude lark restart # 重启
|
|
217
|
+
remote-claude lark status # 查看状态
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
飞书中与机器人对话,可用命令:`/menu`、`/attach`、`/detach`、`/list`、`/help` 等。
|
|
221
|
+
|
|
222
|
+
## 系统要求
|
|
223
|
+
|
|
224
|
+
- **操作系统**: macOS 或 Linux
|
|
225
|
+
- **依赖工具**: [uv](https://docs.astral.sh/uv/)、[tmux](https://github.com/tmux/tmux)、[Claude CLI](https://claude.ai/code)
|
|
226
|
+
- **可选**: 飞书企业自建应用
|
|
227
|
+
|
|
228
|
+
## 文档
|
|
229
|
+
|
|
230
|
+
- [CLAUDE.md](./CLAUDE.md) — 项目架构和开发说明
|
|
231
|
+
- [LARK_CLIENT_GUIDE.md](./LARK_CLIENT_GUIDE.md) — 飞书客户端完整指南
|
package/bin/cl
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 解析符号链接,兼容 macOS(不支持 readlink -f)
|
|
3
|
+
SOURCE="$0"
|
|
4
|
+
while [ -L "$SOURCE" ]; do
|
|
5
|
+
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
6
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
7
|
+
[[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
8
|
+
done
|
|
9
|
+
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && cd .. && pwd)"
|
|
10
|
+
|
|
11
|
+
# uv 路径兜底
|
|
12
|
+
if ! command -v uv &>/dev/null; then
|
|
13
|
+
[ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# 检查飞书配置
|
|
17
|
+
source "$SCRIPT_DIR/scripts/check-env.sh" "$SCRIPT_DIR"
|
|
18
|
+
|
|
19
|
+
uv run --project "$SCRIPT_DIR" python3 "$SCRIPT_DIR/remote_claude.py" lark start
|
|
20
|
+
uv run --project "$SCRIPT_DIR" python3 "$SCRIPT_DIR/remote_claude.py" start "${PWD}_$(date +%m%d_%H%M%S)" -- --dangerously-skip-permissions --permission-mode=dontAsk "$@"
|
package/bin/cla
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 解析符号链接,兼容 macOS(不支持 readlink -f)
|
|
3
|
+
SOURCE="$0"
|
|
4
|
+
while [ -L "$SOURCE" ]; do
|
|
5
|
+
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
6
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
7
|
+
[[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
8
|
+
done
|
|
9
|
+
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && cd .. && pwd)"
|
|
10
|
+
|
|
11
|
+
# uv 路径兜底
|
|
12
|
+
if ! command -v uv &>/dev/null; then
|
|
13
|
+
[ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# 检查飞书配置
|
|
17
|
+
source "$SCRIPT_DIR/scripts/check-env.sh" "$SCRIPT_DIR"
|
|
18
|
+
|
|
19
|
+
uv run --project "$SCRIPT_DIR" python3 "$SCRIPT_DIR/remote_claude.py" lark start
|
|
20
|
+
uv run --project "$SCRIPT_DIR" python3 "$SCRIPT_DIR/remote_claude.py" start "${PWD}_$(date +%m%d_%H%M%S)" -- "$@"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 解析符号链接,兼容 macOS(不支持 readlink -f)
|
|
3
|
+
SOURCE="$0"
|
|
4
|
+
while [ -L "$SOURCE" ]; do
|
|
5
|
+
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
6
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
7
|
+
[[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
8
|
+
done
|
|
9
|
+
INSTALL_DIR="$(cd -P "$(dirname "$SOURCE")" && cd .. && pwd)"
|
|
10
|
+
|
|
11
|
+
# uv 路径兜底
|
|
12
|
+
if ! command -v uv &>/dev/null; then
|
|
13
|
+
[ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# lark 子命令:检查 .env 配置
|
|
17
|
+
if [ "$1" = "lark" ]; then
|
|
18
|
+
source "$INSTALL_DIR/scripts/check-env.sh" "$INSTALL_DIR"
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
exec uv run --project "$INSTALL_DIR" python3 "$INSTALL_DIR/remote_claude.py" "$@"
|
package/client/client.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
客户端连接器
|
|
3
|
+
|
|
4
|
+
- 终端 raw mode 处理
|
|
5
|
+
- Socket 连接
|
|
6
|
+
- 输入转发
|
|
7
|
+
- 输出显示
|
|
8
|
+
- Ctrl+D 退出
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import sys as _sys
|
|
14
|
+
_sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent.parent)) # 根目录 → protocol, utils
|
|
15
|
+
import sys
|
|
16
|
+
import tty
|
|
17
|
+
import termios
|
|
18
|
+
import signal
|
|
19
|
+
import select
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from utils.protocol import (
|
|
23
|
+
Message, MessageType, InputMessage, ResizeMessage,
|
|
24
|
+
encode_message, decode_message
|
|
25
|
+
)
|
|
26
|
+
from utils.session import get_socket_path, generate_client_id, get_terminal_size
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from stats import track as _track_stats
|
|
30
|
+
except Exception:
|
|
31
|
+
def _track_stats(*args, **kwargs): pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# 特殊按键
|
|
35
|
+
CTRL_D = b'\x04' # Ctrl+D - 退出
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RemoteClient:
|
|
39
|
+
"""远程客户端"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, session_name: str):
|
|
42
|
+
self.session_name = session_name
|
|
43
|
+
self.socket_path = get_socket_path(session_name)
|
|
44
|
+
self.client_id = generate_client_id()
|
|
45
|
+
|
|
46
|
+
# 连接
|
|
47
|
+
self.reader: Optional[asyncio.StreamReader] = None
|
|
48
|
+
self.writer: Optional[asyncio.StreamWriter] = None
|
|
49
|
+
self.buffer = b""
|
|
50
|
+
|
|
51
|
+
# 状态
|
|
52
|
+
self.running = False
|
|
53
|
+
|
|
54
|
+
# 终端设置
|
|
55
|
+
self.old_settings = None
|
|
56
|
+
|
|
57
|
+
async def connect(self) -> bool:
|
|
58
|
+
"""连接到服务器"""
|
|
59
|
+
if not self.socket_path.exists():
|
|
60
|
+
print(f"错误: 会话 '{self.session_name}' 不存在")
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
self.reader, self.writer = await asyncio.open_unix_connection(
|
|
65
|
+
path=str(self.socket_path)
|
|
66
|
+
)
|
|
67
|
+
return True
|
|
68
|
+
except Exception as e:
|
|
69
|
+
print(f"连接失败: {e}")
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
async def run(self):
|
|
73
|
+
"""运行客户端"""
|
|
74
|
+
if not await self.connect():
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
self.running = True
|
|
78
|
+
_track_stats('terminal', 'connect', session_name=self.session_name)
|
|
79
|
+
|
|
80
|
+
# 设置终端 raw mode
|
|
81
|
+
self._setup_terminal()
|
|
82
|
+
|
|
83
|
+
# 设置信号处理
|
|
84
|
+
self._setup_signals()
|
|
85
|
+
|
|
86
|
+
# 发送初始终端尺寸,让 server 将 PTY 调整为实际终端大小
|
|
87
|
+
rows, cols = get_terminal_size()
|
|
88
|
+
await self._send_resize(rows, cols)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# 并行运行输入和输出处理
|
|
92
|
+
await asyncio.gather(
|
|
93
|
+
self._read_server(),
|
|
94
|
+
self._read_stdin(),
|
|
95
|
+
return_exceptions=True
|
|
96
|
+
)
|
|
97
|
+
finally:
|
|
98
|
+
self._cleanup()
|
|
99
|
+
|
|
100
|
+
def _setup_terminal(self):
|
|
101
|
+
"""设置终端 raw mode"""
|
|
102
|
+
if sys.stdin.isatty():
|
|
103
|
+
self.old_settings = termios.tcgetattr(sys.stdin)
|
|
104
|
+
tty.setraw(sys.stdin.fileno())
|
|
105
|
+
|
|
106
|
+
def _restore_terminal(self):
|
|
107
|
+
"""恢复终端设置"""
|
|
108
|
+
if self.old_settings:
|
|
109
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old_settings)
|
|
110
|
+
|
|
111
|
+
def _setup_signals(self):
|
|
112
|
+
"""设置信号处理"""
|
|
113
|
+
signal.signal(signal.SIGWINCH, self._handle_resize)
|
|
114
|
+
|
|
115
|
+
def _handle_resize(self, signum, frame):
|
|
116
|
+
"""处理终端大小变化"""
|
|
117
|
+
if self.running and self.writer:
|
|
118
|
+
rows, cols = get_terminal_size()
|
|
119
|
+
asyncio.create_task(self._send_resize(rows, cols))
|
|
120
|
+
|
|
121
|
+
async def _send_resize(self, rows: int, cols: int):
|
|
122
|
+
"""发送终端大小"""
|
|
123
|
+
msg = ResizeMessage(rows, cols, self.client_id)
|
|
124
|
+
await self._send_message(msg)
|
|
125
|
+
|
|
126
|
+
async def _read_server(self):
|
|
127
|
+
"""读取服务器消息"""
|
|
128
|
+
while self.running:
|
|
129
|
+
try:
|
|
130
|
+
msg = await asyncio.wait_for(self._read_message(), timeout=0.5)
|
|
131
|
+
if msg is None:
|
|
132
|
+
self.running = False
|
|
133
|
+
break
|
|
134
|
+
await self._handle_server_message(msg)
|
|
135
|
+
except asyncio.TimeoutError:
|
|
136
|
+
continue
|
|
137
|
+
except Exception:
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
async def _read_message(self) -> Optional[Message]:
|
|
141
|
+
"""读取一条消息"""
|
|
142
|
+
while True:
|
|
143
|
+
if b"\n" in self.buffer:
|
|
144
|
+
line, self.buffer = self.buffer.split(b"\n", 1)
|
|
145
|
+
try:
|
|
146
|
+
return decode_message(line)
|
|
147
|
+
except Exception:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
data = await self.reader.read(4096)
|
|
152
|
+
if not data:
|
|
153
|
+
return None
|
|
154
|
+
self.buffer += data
|
|
155
|
+
except Exception:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
async def _handle_server_message(self, msg: Message):
|
|
159
|
+
"""处理服务器消息"""
|
|
160
|
+
if msg.type == MessageType.OUTPUT:
|
|
161
|
+
data = msg.get_data()
|
|
162
|
+
sys.stdout.buffer.write(data)
|
|
163
|
+
sys.stdout.buffer.flush()
|
|
164
|
+
|
|
165
|
+
elif msg.type == MessageType.HISTORY:
|
|
166
|
+
data = msg.get_data()
|
|
167
|
+
sys.stdout.buffer.write(data)
|
|
168
|
+
sys.stdout.buffer.flush()
|
|
169
|
+
|
|
170
|
+
async def _read_stdin(self):
|
|
171
|
+
"""读取标准输入"""
|
|
172
|
+
loop = asyncio.get_event_loop()
|
|
173
|
+
|
|
174
|
+
while self.running:
|
|
175
|
+
try:
|
|
176
|
+
# 在线程池中读取标准输入(带超时)
|
|
177
|
+
data = await loop.run_in_executor(None, self._read_stdin_sync)
|
|
178
|
+
if data:
|
|
179
|
+
await self._handle_input(data)
|
|
180
|
+
if not self.running:
|
|
181
|
+
break
|
|
182
|
+
except Exception:
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
def _read_stdin_sync(self) -> bytes:
|
|
186
|
+
"""同步读取标准输入(带超时,便于检查 running 状态)"""
|
|
187
|
+
# 使用 select 等待输入,超时 0.1 秒
|
|
188
|
+
rlist, _, _ = select.select([sys.stdin], [], [], 0.1)
|
|
189
|
+
if rlist:
|
|
190
|
+
return os.read(sys.stdin.fileno(), 1024)
|
|
191
|
+
return b""
|
|
192
|
+
|
|
193
|
+
async def _handle_input(self, data: bytes):
|
|
194
|
+
"""处理输入"""
|
|
195
|
+
# Ctrl+D 退出
|
|
196
|
+
if data == CTRL_D:
|
|
197
|
+
self.running = False
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
# 其他按键都发送给 Claude
|
|
201
|
+
_track_stats('terminal', 'input', session_name=self.session_name,
|
|
202
|
+
value=len(data))
|
|
203
|
+
await self._send_input(data)
|
|
204
|
+
|
|
205
|
+
async def _send_input(self, data: bytes):
|
|
206
|
+
"""发送输入"""
|
|
207
|
+
msg = InputMessage(data, self.client_id)
|
|
208
|
+
await self._send_message(msg)
|
|
209
|
+
|
|
210
|
+
async def _send_message(self, msg: Message):
|
|
211
|
+
"""发送消息"""
|
|
212
|
+
if self.writer:
|
|
213
|
+
try:
|
|
214
|
+
data = encode_message(msg)
|
|
215
|
+
self.writer.write(data)
|
|
216
|
+
await self.writer.drain()
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def _cleanup(self):
|
|
221
|
+
"""清理"""
|
|
222
|
+
self.running = False
|
|
223
|
+
_track_stats('terminal', 'disconnect', session_name=self.session_name)
|
|
224
|
+
self._restore_terminal()
|
|
225
|
+
|
|
226
|
+
if self.writer:
|
|
227
|
+
try:
|
|
228
|
+
self.writer.close()
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
print("\n已断开连接")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run_client(session_name: str):
|
|
236
|
+
"""运行客户端"""
|
|
237
|
+
client = RemoteClient(session_name)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
asyncio.run(client.run())
|
|
241
|
+
except KeyboardInterrupt:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
import argparse
|
|
247
|
+
parser = argparse.ArgumentParser(description="Remote Claude Client")
|
|
248
|
+
parser.add_argument("session_name", help="会话名称")
|
|
249
|
+
args = parser.parse_args()
|
|
250
|
+
|
|
251
|
+
run_client(args.session_name)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
捕获 remote_claude 的实际输出用于分析
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
10
|
+
|
|
11
|
+
from utils.protocol import Message, MessageType, decode_message
|
|
12
|
+
from utils.session import get_socket_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def capture_output(session_name: str, duration: int = 30):
|
|
16
|
+
"""捕获会话输出"""
|
|
17
|
+
socket_path = get_socket_path(session_name)
|
|
18
|
+
|
|
19
|
+
if not socket_path.exists():
|
|
20
|
+
print(f"会话 {session_name} 不存在")
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
print(f"连接到 {session_name}...")
|
|
24
|
+
|
|
25
|
+
reader, writer = await asyncio.open_unix_connection(path=str(socket_path))
|
|
26
|
+
|
|
27
|
+
print(f"已连接,捕获 {duration} 秒的输出...")
|
|
28
|
+
print("=" * 60)
|
|
29
|
+
|
|
30
|
+
buffer = b""
|
|
31
|
+
outputs = []
|
|
32
|
+
|
|
33
|
+
async def read_messages():
|
|
34
|
+
nonlocal buffer
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
data = await asyncio.wait_for(reader.read(4096), timeout=1.0)
|
|
38
|
+
if not data:
|
|
39
|
+
break
|
|
40
|
+
buffer += data
|
|
41
|
+
|
|
42
|
+
while b"\n" in buffer:
|
|
43
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
44
|
+
try:
|
|
45
|
+
msg = decode_message(line)
|
|
46
|
+
if msg.type == MessageType.OUTPUT:
|
|
47
|
+
raw_data = msg.get_data()
|
|
48
|
+
outputs.append(raw_data)
|
|
49
|
+
print(f"收到 {len(raw_data)} 字节:")
|
|
50
|
+
print(f" 原始: {raw_data[:100]}...")
|
|
51
|
+
print(f" 解码: {raw_data.decode('utf-8', errors='replace')[:100]}...")
|
|
52
|
+
print()
|
|
53
|
+
except Exception as e:
|
|
54
|
+
print(f"解析错误: {e}")
|
|
55
|
+
except asyncio.TimeoutError:
|
|
56
|
+
continue
|
|
57
|
+
except Exception as e:
|
|
58
|
+
print(f"读取错误: {e}")
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
await asyncio.wait_for(read_messages(), timeout=duration)
|
|
63
|
+
except asyncio.TimeoutError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
writer.close()
|
|
67
|
+
await writer.wait_closed()
|
|
68
|
+
|
|
69
|
+
print("=" * 60)
|
|
70
|
+
print(f"共收到 {len(outputs)} 条输出消息")
|
|
71
|
+
|
|
72
|
+
# 保存原始输出到文件
|
|
73
|
+
output_file = Path("/tmp/claude_raw_output.bin")
|
|
74
|
+
with open(output_file, "wb") as f:
|
|
75
|
+
for data in outputs:
|
|
76
|
+
f.write(data)
|
|
77
|
+
f.write(b"\n---\n")
|
|
78
|
+
|
|
79
|
+
print(f"原始输出已保存到 {output_file}")
|
|
80
|
+
|
|
81
|
+
# 合并并分析
|
|
82
|
+
all_data = b"".join(outputs)
|
|
83
|
+
print(f"\n合并后总长度: {len(all_data)} 字节")
|
|
84
|
+
print(f"合并后内容:\n{all_data.decode('utf-8', errors='replace')}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
session = sys.argv[1] if len(sys.argv) > 1 else "test"
|
|
89
|
+
duration = int(sys.argv[2]) if len(sys.argv) > 2 else 30
|
|
90
|
+
|
|
91
|
+
asyncio.run(capture_output(session, duration))
|