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.
@@ -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)