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/utils/session.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
工具函数
|
|
3
|
+
|
|
4
|
+
- tmux 操作封装
|
|
5
|
+
- Socket 路径管理
|
|
6
|
+
- 通用工具
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, List
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# 常量
|
|
18
|
+
SOCKET_DIR = Path("/tmp/remote-claude")
|
|
19
|
+
TMUX_SESSION_PREFIX = "rc-"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _safe_filename(session_name: str) -> str:
|
|
23
|
+
"""将会话名转为安全文件名(/ 和 . 替换为 _)"""
|
|
24
|
+
return session_name.replace('/', '_').replace('.', '_')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_socket_path(session_name: str) -> Path:
|
|
28
|
+
"""获取会话的 socket 路径"""
|
|
29
|
+
return SOCKET_DIR / f"{_safe_filename(session_name)}.sock"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_pid_file(session_name: str) -> Path:
|
|
33
|
+
"""获取会话的 PID 文件路径"""
|
|
34
|
+
return SOCKET_DIR / f"{_safe_filename(session_name)}.pid"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_mq_path(session_name: str) -> Path:
|
|
38
|
+
"""获取会话的共享状态 mmap 文件路径"""
|
|
39
|
+
return SOCKET_DIR / f"{_safe_filename(session_name)}.mq"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_socket_dir():
|
|
43
|
+
"""确保 socket 目录存在"""
|
|
44
|
+
SOCKET_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def generate_client_id() -> str:
|
|
48
|
+
"""生成客户端 ID"""
|
|
49
|
+
return uuid.uuid4().hex[:8]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_tmux_session_name(session_name: str) -> str:
|
|
53
|
+
"""获取 tmux 会话名称"""
|
|
54
|
+
return f"{TMUX_SESSION_PREFIX}{_safe_filename(session_name)}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ============== tmux 操作 ==============
|
|
58
|
+
|
|
59
|
+
def tmux_session_exists(session_name: str) -> bool:
|
|
60
|
+
"""检查 tmux 会话是否存在"""
|
|
61
|
+
tmux_name = get_tmux_session_name(session_name)
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["tmux", "has-session", "-t", tmux_name],
|
|
64
|
+
capture_output=True
|
|
65
|
+
)
|
|
66
|
+
return result.returncode == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tmux_create_session(session_name: str, command: str, detached: bool = True) -> bool:
|
|
70
|
+
"""创建 tmux 会话并运行命令"""
|
|
71
|
+
tmux_name = get_tmux_session_name(session_name)
|
|
72
|
+
args = ["tmux", "new-session", "-s", tmux_name]
|
|
73
|
+
if detached:
|
|
74
|
+
args.append("-d")
|
|
75
|
+
args.extend(["-x", "200", "-y", "50"]) # 默认大小
|
|
76
|
+
args.append(command)
|
|
77
|
+
|
|
78
|
+
result = subprocess.run(args, capture_output=True)
|
|
79
|
+
if result.returncode == 0:
|
|
80
|
+
# 启用鼠标支持,允许在 tmux 窗口内用鼠标滚轮查看历史输出
|
|
81
|
+
subprocess.run(
|
|
82
|
+
["tmux", "set-option", "-t", tmux_name, "-g", "mouse", "on"],
|
|
83
|
+
capture_output=True
|
|
84
|
+
)
|
|
85
|
+
return result.returncode == 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tmux_new_window(session_name: str, window_name: str, command: str) -> bool:
|
|
89
|
+
"""在 tmux 会话中创建新窗口"""
|
|
90
|
+
tmux_name = get_tmux_session_name(session_name)
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
["tmux", "new-window", "-t", tmux_name, "-n", window_name, command],
|
|
93
|
+
capture_output=True
|
|
94
|
+
)
|
|
95
|
+
return result.returncode == 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def tmux_attach(session_name: str, window: Optional[str] = None) -> bool:
|
|
99
|
+
"""附加到 tmux 会话"""
|
|
100
|
+
tmux_name = get_tmux_session_name(session_name)
|
|
101
|
+
target = tmux_name
|
|
102
|
+
if window:
|
|
103
|
+
target = f"{tmux_name}:{window}"
|
|
104
|
+
|
|
105
|
+
result = subprocess.run(["tmux", "attach-session", "-t", target])
|
|
106
|
+
return result.returncode == 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def tmux_kill_session(session_name: str) -> bool:
|
|
110
|
+
"""终止 tmux 会话"""
|
|
111
|
+
tmux_name = get_tmux_session_name(session_name)
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
["tmux", "kill-session", "-t", tmux_name],
|
|
114
|
+
capture_output=True
|
|
115
|
+
)
|
|
116
|
+
return result.returncode == 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def tmux_list_sessions() -> List[str]:
|
|
120
|
+
"""列出所有 remote-claude 相关的 tmux 会话"""
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["tmux", "list-sessions", "-F", "#{session_name}"],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True
|
|
125
|
+
)
|
|
126
|
+
if result.returncode != 0:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
sessions = []
|
|
130
|
+
for line in result.stdout.strip().split("\n"):
|
|
131
|
+
if line.startswith(TMUX_SESSION_PREFIX):
|
|
132
|
+
sessions.append(line[len(TMUX_SESSION_PREFIX):])
|
|
133
|
+
return sessions
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def tmux_send_keys(session_name: str, keys: str, window: Optional[str] = None) -> bool:
|
|
137
|
+
"""向 tmux 会话发送按键"""
|
|
138
|
+
tmux_name = get_tmux_session_name(session_name)
|
|
139
|
+
target = tmux_name
|
|
140
|
+
if window:
|
|
141
|
+
target = f"{tmux_name}:{window}"
|
|
142
|
+
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
["tmux", "send-keys", "-t", target, keys],
|
|
145
|
+
capture_output=True
|
|
146
|
+
)
|
|
147
|
+
return result.returncode == 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def tmux_select_window(session_name: str, window: str) -> bool:
|
|
151
|
+
"""选择 tmux 窗口"""
|
|
152
|
+
tmux_name = get_tmux_session_name(session_name)
|
|
153
|
+
result = subprocess.run(
|
|
154
|
+
["tmux", "select-window", "-t", f"{tmux_name}:{window}"],
|
|
155
|
+
capture_output=True
|
|
156
|
+
)
|
|
157
|
+
return result.returncode == 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ============== 会话管理 ==============
|
|
161
|
+
|
|
162
|
+
def get_process_cwd(pid: int) -> Optional[str]:
|
|
163
|
+
"""获取进程的工作目录(macOS/Linux,通过 lsof)"""
|
|
164
|
+
try:
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["lsof", "-p", str(pid), "-a", "-d", "cwd", "-F", "n"],
|
|
167
|
+
capture_output=True, text=True, timeout=5
|
|
168
|
+
)
|
|
169
|
+
for line in result.stdout.splitlines():
|
|
170
|
+
if line.startswith("n"):
|
|
171
|
+
return line[1:].strip()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def list_active_sessions() -> List[dict]:
|
|
178
|
+
"""列出所有活跃会话"""
|
|
179
|
+
ensure_socket_dir()
|
|
180
|
+
sessions = []
|
|
181
|
+
|
|
182
|
+
for sock_file in SOCKET_DIR.glob("*.sock"):
|
|
183
|
+
session_name = sock_file.stem
|
|
184
|
+
pid_file = get_pid_file(session_name)
|
|
185
|
+
|
|
186
|
+
# 检查 socket 文件是否有效(进程是否存在)
|
|
187
|
+
if pid_file.exists():
|
|
188
|
+
try:
|
|
189
|
+
pid = int(pid_file.read_text().strip())
|
|
190
|
+
# 检查进程是否存在
|
|
191
|
+
os.kill(pid, 0)
|
|
192
|
+
# 获取进程 CWD
|
|
193
|
+
cwd = get_process_cwd(pid)
|
|
194
|
+
# 获取启动时间(PID 文件的修改时间,文件可能已被并发清理)
|
|
195
|
+
import datetime
|
|
196
|
+
try:
|
|
197
|
+
mtime = pid_file.stat().st_mtime
|
|
198
|
+
start_time = datetime.datetime.fromtimestamp(mtime).strftime("%H:%M")
|
|
199
|
+
except OSError:
|
|
200
|
+
mtime = 0
|
|
201
|
+
start_time = "?"
|
|
202
|
+
sessions.append({
|
|
203
|
+
"name": session_name,
|
|
204
|
+
"socket": str(sock_file),
|
|
205
|
+
"pid": pid,
|
|
206
|
+
"cwd": cwd or "",
|
|
207
|
+
"start_time": start_time,
|
|
208
|
+
"mtime": mtime,
|
|
209
|
+
"tmux": tmux_session_exists(session_name)
|
|
210
|
+
})
|
|
211
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
212
|
+
# 进程不存在或文件被并发清理,清理残留文件
|
|
213
|
+
cleanup_session(session_name)
|
|
214
|
+
else:
|
|
215
|
+
# 没有 PID 文件,清理 socket
|
|
216
|
+
sock_file.unlink(missing_ok=True)
|
|
217
|
+
|
|
218
|
+
sessions.sort(key=lambda s: s.get("mtime", 0), reverse=True)
|
|
219
|
+
return sessions
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cleanup_session(session_name: str):
|
|
223
|
+
"""清理会话残留文件"""
|
|
224
|
+
sock_path = get_socket_path(session_name)
|
|
225
|
+
pid_file = get_pid_file(session_name)
|
|
226
|
+
|
|
227
|
+
sock_path.unlink(missing_ok=True)
|
|
228
|
+
pid_file.unlink(missing_ok=True)
|
|
229
|
+
get_mq_path(session_name).unlink(missing_ok=True)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def is_session_active(session_name: str) -> bool:
|
|
233
|
+
"""检查会话是否活跃"""
|
|
234
|
+
sock_path = get_socket_path(session_name)
|
|
235
|
+
pid_file = get_pid_file(session_name)
|
|
236
|
+
|
|
237
|
+
if not sock_path.exists() or not pid_file.exists():
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
pid = int(pid_file.read_text().strip())
|
|
242
|
+
os.kill(pid, 0)
|
|
243
|
+
return True
|
|
244
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ============== 终端工具 ==============
|
|
249
|
+
|
|
250
|
+
def get_terminal_size() -> tuple:
|
|
251
|
+
"""获取终端大小"""
|
|
252
|
+
try:
|
|
253
|
+
size = os.get_terminal_size()
|
|
254
|
+
return (size.lines, size.columns)
|
|
255
|
+
except OSError:
|
|
256
|
+
return (24, 80) # 默认大小
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ============== 飞书客户端管理 ==============
|
|
260
|
+
|
|
261
|
+
def get_lark_pid_file() -> Path:
|
|
262
|
+
"""获取飞书客户端的 PID 文件路径"""
|
|
263
|
+
return SOCKET_DIR / "lark.pid"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_lark_status_file() -> Path:
|
|
267
|
+
"""获取飞书客户端的状态文件路径"""
|
|
268
|
+
return SOCKET_DIR / "lark.status"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def is_lark_running() -> bool:
|
|
272
|
+
"""检查飞书客户端是否正在运行"""
|
|
273
|
+
pid_file = get_lark_pid_file()
|
|
274
|
+
|
|
275
|
+
if not pid_file.exists():
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
pid = int(pid_file.read_text().strip())
|
|
280
|
+
os.kill(pid, 0)
|
|
281
|
+
return True
|
|
282
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_lark_pid() -> Optional[int]:
|
|
287
|
+
"""获取飞书客户端的 PID"""
|
|
288
|
+
pid_file = get_lark_pid_file()
|
|
289
|
+
|
|
290
|
+
if not pid_file.exists():
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
pid = int(pid_file.read_text().strip())
|
|
295
|
+
os.kill(pid, 0)
|
|
296
|
+
return pid
|
|
297
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def get_lark_status() -> Optional[dict]:
|
|
302
|
+
"""获取飞书客户端状态信息
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
dict: 包含 pid, start_time, uptime 等信息
|
|
306
|
+
None: 如果客户端未运行
|
|
307
|
+
"""
|
|
308
|
+
pid = get_lark_pid()
|
|
309
|
+
if pid is None:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
status_file = get_lark_status_file()
|
|
313
|
+
import datetime
|
|
314
|
+
|
|
315
|
+
# 获取启动时间
|
|
316
|
+
if status_file.exists():
|
|
317
|
+
try:
|
|
318
|
+
import json
|
|
319
|
+
status_data = json.loads(status_file.read_text())
|
|
320
|
+
start_timestamp = status_data.get("start_time")
|
|
321
|
+
start_time_str = datetime.datetime.fromtimestamp(start_timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
|
322
|
+
|
|
323
|
+
# 计算运行时间
|
|
324
|
+
uptime_seconds = int(datetime.datetime.now().timestamp() - start_timestamp)
|
|
325
|
+
uptime_str = format_uptime(uptime_seconds)
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
"pid": pid,
|
|
329
|
+
"start_time": start_time_str,
|
|
330
|
+
"uptime": uptime_str,
|
|
331
|
+
"uptime_seconds": uptime_seconds
|
|
332
|
+
}
|
|
333
|
+
except (json.JSONDecodeError, ValueError, OSError):
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
# 如果状态文件不存在或无法读取,使用 PID 文件的修改时间
|
|
337
|
+
pid_file = get_lark_pid_file()
|
|
338
|
+
try:
|
|
339
|
+
mtime = pid_file.stat().st_mtime
|
|
340
|
+
start_time_str = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
341
|
+
uptime_seconds = int(datetime.datetime.now().timestamp() - mtime)
|
|
342
|
+
uptime_str = format_uptime(uptime_seconds)
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
"pid": pid,
|
|
346
|
+
"start_time": start_time_str,
|
|
347
|
+
"uptime": uptime_str,
|
|
348
|
+
"uptime_seconds": uptime_seconds
|
|
349
|
+
}
|
|
350
|
+
except OSError:
|
|
351
|
+
return {
|
|
352
|
+
"pid": pid,
|
|
353
|
+
"start_time": "未知",
|
|
354
|
+
"uptime": "未知",
|
|
355
|
+
"uptime_seconds": 0
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def format_uptime(seconds: int) -> str:
|
|
360
|
+
"""格式化运行时间
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
seconds: 秒数
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
str: 格式化后的运行时间,如 "2天3小时5分钟"
|
|
367
|
+
"""
|
|
368
|
+
if seconds < 60:
|
|
369
|
+
return f"{seconds}秒"
|
|
370
|
+
|
|
371
|
+
minutes = seconds // 60
|
|
372
|
+
if minutes < 60:
|
|
373
|
+
return f"{minutes}分钟"
|
|
374
|
+
|
|
375
|
+
hours = minutes // 60
|
|
376
|
+
minutes = minutes % 60
|
|
377
|
+
if hours < 24:
|
|
378
|
+
return f"{hours}小时{minutes}分钟"
|
|
379
|
+
|
|
380
|
+
days = hours // 24
|
|
381
|
+
hours = hours % 24
|
|
382
|
+
return f"{days}天{hours}小时{minutes}分钟"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def save_lark_status(pid: int):
|
|
386
|
+
"""保存飞书客户端状态信息
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
pid: 进程 PID
|
|
390
|
+
"""
|
|
391
|
+
import json
|
|
392
|
+
import datetime
|
|
393
|
+
|
|
394
|
+
status_file = get_lark_status_file()
|
|
395
|
+
status_data = {
|
|
396
|
+
"pid": pid,
|
|
397
|
+
"start_time": datetime.datetime.now().timestamp()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
status_file.write_text(json.dumps(status_data))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def cleanup_lark():
|
|
404
|
+
"""清理飞书客户端残留文件"""
|
|
405
|
+
pid_file = get_lark_pid_file()
|
|
406
|
+
status_file = get_lark_status_file()
|
|
407
|
+
|
|
408
|
+
pid_file.unlink(missing_ok=True)
|
|
409
|
+
status_file.unlink(missing_ok=True)
|