remote-claude 0.2.9 → 0.2.11
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 +4 -0
- package/README.md +7 -2
- package/bin/cdx +20 -0
- package/bin/cx +20 -0
- package/bin/remote-claude +12 -2
- package/client/__init__.py +3 -0
- package/init.sh +31 -2
- package/lark_client/card_builder.py +14 -7
- package/lark_client/config.py +10 -0
- package/lark_client/lark_handler.py +34 -4
- package/lark_client/main.py +52 -8
- package/lark_client/session_bridge.py +1 -1
- package/package.json +2 -2
- package/remote_claude.py +75 -15
- package/server/__init__.py +1 -0
- package/server/server.py +168 -22
- package/server/shared_state.py +3 -2
- package/utils/__init__.py +3 -0
- package/utils/session.py +25 -2
package/.env.example
CHANGED
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ cd remote_claude
|
|
|
43
43
|
./init.sh
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
`init.sh` 会自动安装 uv、tmux 等依赖,配置飞书环境(可选),并写入 `cla` / `cl` 快捷命令。执行完成后重启终端生效。
|
|
46
|
+
`init.sh` 会自动安装 uv、tmux 等依赖,配置飞书环境(可选),并写入 `cla` / `cl` / `cx` / `cdx` 快捷命令。执行完成后重启终端生效。
|
|
47
47
|
|
|
48
48
|
### 2. 启动
|
|
49
49
|
|
|
@@ -51,6 +51,8 @@ cd remote_claude
|
|
|
51
51
|
|------|------|
|
|
52
52
|
| `cla` | 启动 Claude (以当前目录路径为会话名) |
|
|
53
53
|
| `cl` | 同 `cla`,但跳过权限确认 |
|
|
54
|
+
| `cx` | 启动 Codex (以当前目录路径为会话名,跳过权限确认) |
|
|
55
|
+
| `cdx` | 同 `cx`,但需要确认权限 |
|
|
54
56
|
| `remote-claude` | 管理工具(一般不用)|
|
|
55
57
|
|
|
56
58
|
### 3. 从其他终端连接(比较少用)
|
|
@@ -210,6 +212,8 @@ remote-claude attach <会话名>
|
|
|
210
212
|
|------|------|
|
|
211
213
|
| `cla` | 启动飞书客户端 + 以当前目录路径为会话名启动 Claude |
|
|
212
214
|
| `cl` | 同 `cla`,但跳过权限确认 |
|
|
215
|
+
| `cx` | 启动飞书客户端 + 以当前目录路径为会话名启动 Codex(跳过权限确认)|
|
|
216
|
+
| `cdx` | 同 `cx`,但需要确认权限 |
|
|
213
217
|
|
|
214
218
|
### 管理命令 (一般不需要)
|
|
215
219
|
|
|
@@ -260,7 +264,8 @@ CLAUDE_COMMAND=/usr/local/bin/claude
|
|
|
260
264
|
## 系统要求
|
|
261
265
|
|
|
262
266
|
- **操作系统**: macOS 或 Linux
|
|
263
|
-
- **依赖工具**: [uv](https://docs.astral.sh/uv/)、[tmux](https://github.com/tmux/tmux)
|
|
267
|
+
- **依赖工具**: [uv](https://docs.astral.sh/uv/)、[tmux](https://github.com/tmux/tmux)
|
|
268
|
+
- **CLI 工具**: [Claude CLI](https://claude.ai/code) 或 [Codex CLI](https://github.com/openai/codex)
|
|
264
269
|
- **可选**: 飞书企业自建应用
|
|
265
270
|
|
|
266
271
|
## 文档
|
package/bin/cdx
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)" --cli codex -- "$@"
|
package/bin/cx
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)" --cli codex -- --dangerously-bypass-approvals-and-sandbox "$@"
|
package/bin/remote-claude
CHANGED
|
@@ -21,8 +21,18 @@ if [ "$1" = "log" ]; then
|
|
|
21
21
|
SESSION_SAFE=$(echo "$2" | tr '/.' '__')
|
|
22
22
|
LOG_FILE="$LOG_DIR/${SESSION_SAFE}_messages.log"
|
|
23
23
|
else
|
|
24
|
-
# 找最后启动的 session
|
|
25
|
-
|
|
24
|
+
# 找最后启动的 session(按创建时间排序,排除 lark.pid)
|
|
25
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
26
|
+
# macOS: stat -f "%B" 返回创建时间(秒)
|
|
27
|
+
LATEST_PID=$(ls "$LOG_DIR"/_*.pid 2>/dev/null | while read f; do
|
|
28
|
+
stat -f "%B:%N" "$f" 2>/dev/null
|
|
29
|
+
done | sort -rn | head -1 | cut -d: -f2)
|
|
30
|
+
else
|
|
31
|
+
# Linux: stat -c "%W" 返回创建时间(秒)
|
|
32
|
+
LATEST_PID=$(ls "$LOG_DIR"/_*.pid 2>/dev/null | while read f; do
|
|
33
|
+
stat -c "%W:%N" "$f" 2>/dev/null
|
|
34
|
+
done | sort -rn | head -1 | cut -d: -f2)
|
|
35
|
+
fi
|
|
26
36
|
if [ -n "$LATEST_PID" ]; then
|
|
27
37
|
SESSION_SAFE=$(basename "$LATEST_PID" .pid)
|
|
28
38
|
LOG_FILE="$LOG_DIR/${SESSION_SAFE}_messages.log"
|
package/init.sh
CHANGED
|
@@ -350,6 +350,26 @@ check_claude() {
|
|
|
350
350
|
fi
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
# 检查 Codex CLI
|
|
354
|
+
check_codex() {
|
|
355
|
+
print_header "检查 Codex CLI"
|
|
356
|
+
|
|
357
|
+
if command -v codex &> /dev/null; then
|
|
358
|
+
print_success "Codex CLI 已安装"
|
|
359
|
+
return
|
|
360
|
+
fi
|
|
361
|
+
|
|
362
|
+
print_warning "未找到 Codex CLI"
|
|
363
|
+
print_info "请运行以下命令安装 Codex CLI:"
|
|
364
|
+
print_info " npm install -g @openai/codex"
|
|
365
|
+
print_info "或访问 https://github.com/openai/codex 了解更多"
|
|
366
|
+
|
|
367
|
+
if $NPM_MODE; then
|
|
368
|
+
print_info "(npm 模式:跳过交互,请安装后重新运行)"
|
|
369
|
+
return
|
|
370
|
+
fi
|
|
371
|
+
}
|
|
372
|
+
|
|
353
373
|
# 安装 Python 依赖
|
|
354
374
|
install_dependencies() {
|
|
355
375
|
print_header "安装 Python 依赖"
|
|
@@ -449,7 +469,7 @@ configure_shell() {
|
|
|
449
469
|
print_header "安装快捷命令"
|
|
450
470
|
|
|
451
471
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
452
|
-
chmod +x "$SCRIPT_DIR/bin/cla" "$SCRIPT_DIR/bin/cl" "$SCRIPT_DIR/bin/remote-claude" 2>/dev/null || true
|
|
472
|
+
chmod +x "$SCRIPT_DIR/bin/cla" "$SCRIPT_DIR/bin/cl" "$SCRIPT_DIR/bin/cx" "$SCRIPT_DIR/bin/cdx" "$SCRIPT_DIR/bin/remote-claude" 2>/dev/null || true
|
|
453
473
|
|
|
454
474
|
# 优先 /usr/local/bin,权限不够则选 ~/bin 或 ~/.local/bin 中已在 PATH 里的
|
|
455
475
|
BIN_DIR="/usr/local/bin"
|
|
@@ -479,15 +499,21 @@ configure_shell() {
|
|
|
479
499
|
mkdir -p "$BIN_DIR"
|
|
480
500
|
ln -sf "$SCRIPT_DIR/bin/cla" "$BIN_DIR/cla" 2>/dev/null || true
|
|
481
501
|
ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl" 2>/dev/null || true
|
|
502
|
+
ln -sf "$SCRIPT_DIR/bin/cx" "$BIN_DIR/cx" 2>/dev/null || true
|
|
503
|
+
ln -sf "$SCRIPT_DIR/bin/cdx" "$BIN_DIR/cdx" 2>/dev/null || true
|
|
482
504
|
ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude" 2>/dev/null || true
|
|
483
505
|
else
|
|
484
506
|
ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl" 2>/dev/null || true
|
|
507
|
+
ln -sf "$SCRIPT_DIR/bin/cx" "$BIN_DIR/cx" 2>/dev/null || true
|
|
508
|
+
ln -sf "$SCRIPT_DIR/bin/cdx" "$BIN_DIR/cdx" 2>/dev/null || true
|
|
485
509
|
ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude" 2>/dev/null || true
|
|
486
510
|
fi
|
|
487
511
|
|
|
488
|
-
print_success "已安装 cla、cl 和 remote-claude 到 $BIN_DIR"
|
|
512
|
+
print_success "已安装 cla、cl、cx、cdx 和 remote-claude 到 $BIN_DIR"
|
|
489
513
|
print_info " cla - 启动飞书客户端 + 以当前目录路径+时间戳为会话名启动 Claude"
|
|
490
514
|
print_info " cl - 同 cla,但跳过权限确认"
|
|
515
|
+
print_info " cx - 启动飞书客户端 + 以当前目录路径+时间戳为会话名启动 Codex(跳过权限)"
|
|
516
|
+
print_info " cdx - 同 cx,但需确认权限"
|
|
491
517
|
print_info " remote-claude - Remote Claude 主命令(start/attach/list/kill/lark)"
|
|
492
518
|
|
|
493
519
|
# 安装 shell 自动补全
|
|
@@ -536,6 +562,8 @@ ${YELLOW}快捷命令:${NC}
|
|
|
536
562
|
|
|
537
563
|
${GREEN}cla${NC} - 启动飞书客户端 + 以当前目录+时间戳为会话名启动 Claude
|
|
538
564
|
${GREEN}cl${NC} - 同 cla,但跳过权限确认
|
|
565
|
+
${GREEN}cx${NC} - 启动飞书客户端 + 以当前目录+时间戳为会话名启动 Codex(跳过权限)
|
|
566
|
+
${GREEN}cdx${NC} - 同 cx,但需确认权限
|
|
539
567
|
|
|
540
568
|
详细使用说明请阅读 README.md
|
|
541
569
|
|
|
@@ -565,6 +593,7 @@ main() {
|
|
|
565
593
|
check_uv
|
|
566
594
|
check_tmux
|
|
567
595
|
check_claude
|
|
596
|
+
check_codex
|
|
568
597
|
install_dependencies
|
|
569
598
|
if ! $NPM_MODE; then
|
|
570
599
|
configure_lark
|
|
@@ -784,29 +784,36 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
|
|
|
784
784
|
name = s["name"]
|
|
785
785
|
cwd = s.get("cwd", "")
|
|
786
786
|
start_time = s.get("start_time", "")
|
|
787
|
+
cli_type = s.get("cli_type", "claude")
|
|
787
788
|
is_current = (name == current_session)
|
|
788
789
|
|
|
790
|
+
# CLI 类型颜色和标签:Claude=黄色,Codex=绿色
|
|
791
|
+
cli_color = "yellow" if cli_type == "claude" else "green"
|
|
792
|
+
cli_label = CLI_NAMES.get(cli_type, "Claude")
|
|
793
|
+
|
|
789
794
|
status_icon = "🟢" if is_current else "⚪"
|
|
790
795
|
current_label = "(当前)" if is_current else ""
|
|
791
796
|
if cwd:
|
|
792
797
|
short_name = cwd.rstrip("/").rsplit("/", 1)[-1] or name
|
|
793
798
|
else:
|
|
794
799
|
short_name = name
|
|
795
|
-
|
|
800
|
+
|
|
801
|
+
# 构建4行内容:名字、cli类型、启动时间、目录
|
|
802
|
+
lines = [f"{status_icon} **{short_name}**{current_label}"]
|
|
803
|
+
lines.append(f"<font color=\"{cli_color}\">{cli_label}</font>")
|
|
804
|
+
|
|
796
805
|
if start_time:
|
|
797
|
-
|
|
806
|
+
lines.append(f"启动:{start_time}")
|
|
807
|
+
|
|
798
808
|
if cwd:
|
|
799
809
|
home = os.path.expanduser("~")
|
|
800
810
|
display_cwd = cwd.replace(home, "~")
|
|
801
811
|
if len(display_cwd) > 40:
|
|
802
812
|
parts = display_cwd.rstrip("/").rsplit("/", 2)
|
|
803
813
|
display_cwd = "…/" + "/".join(parts[-2:]) if len(parts) > 2 else display_cwd[-40:]
|
|
804
|
-
|
|
805
|
-
meta_str = " ".join(meta_parts) if meta_parts else ""
|
|
814
|
+
lines.append(f"`{display_cwd}`")
|
|
806
815
|
|
|
807
|
-
header_text =
|
|
808
|
-
if meta_str:
|
|
809
|
-
header_text += f"\n{meta_str}"
|
|
816
|
+
header_text = "\n".join(lines)
|
|
810
817
|
|
|
811
818
|
if is_current:
|
|
812
819
|
btn_label = "断开连接"
|
package/lark_client/config.py
CHANGED
|
@@ -38,3 +38,13 @@ GROUP_NAME_PREFIX = os.getenv("GROUP_NAME_PREFIX", "【Remote-Claude】")
|
|
|
38
38
|
|
|
39
39
|
# 流式卡片配置
|
|
40
40
|
MAX_CARD_BLOCKS = int(os.getenv("MAX_CARD_BLOCKS", "50"))
|
|
41
|
+
|
|
42
|
+
# lark_client 日志级别(可选,默认 INFO)
|
|
43
|
+
# 支持: DEBUG / INFO / WARNING / ERROR
|
|
44
|
+
_LARK_LOG_LEVEL = os.getenv("LARK_LOG_LEVEL", "INFO").upper()
|
|
45
|
+
LARK_LOG_LEVEL = {
|
|
46
|
+
"DEBUG": 10,
|
|
47
|
+
"INFO": 20,
|
|
48
|
+
"WARNING": 30,
|
|
49
|
+
"ERROR": 40,
|
|
50
|
+
}.get(_LARK_LOG_LEVEL, 20) # 默认 INFO
|
|
@@ -318,15 +318,20 @@ class LarkHandler:
|
|
|
318
318
|
server_script = script_dir / "server" / "server.py"
|
|
319
319
|
cmd = [sys.executable, str(server_script), session_name]
|
|
320
320
|
|
|
321
|
-
logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {cmd}")
|
|
321
|
+
logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {' '.join(cmd)}")
|
|
322
322
|
_track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
|
|
323
323
|
|
|
324
324
|
try:
|
|
325
325
|
import os as _os
|
|
326
|
+
from datetime import datetime as _datetime
|
|
326
327
|
env = _os.environ.copy()
|
|
327
328
|
env.pop("CLAUDECODE", None)
|
|
328
329
|
|
|
329
|
-
|
|
330
|
+
from utils.session import USER_DATA_DIR
|
|
331
|
+
log_path = USER_DATA_DIR / "startup.log"
|
|
332
|
+
start_time = _datetime.now()
|
|
333
|
+
|
|
334
|
+
proc = subprocess.Popen(
|
|
330
335
|
cmd,
|
|
331
336
|
stdout=subprocess.DEVNULL,
|
|
332
337
|
stderr=subprocess.DEVNULL,
|
|
@@ -335,13 +340,38 @@ class LarkHandler:
|
|
|
335
340
|
env=env,
|
|
336
341
|
)
|
|
337
342
|
|
|
343
|
+
def _read_log_since(since):
|
|
344
|
+
if not log_path.exists():
|
|
345
|
+
return ""
|
|
346
|
+
lines = []
|
|
347
|
+
for line in log_path.read_text(encoding="utf-8").splitlines():
|
|
348
|
+
try:
|
|
349
|
+
ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
|
|
350
|
+
if ts >= since:
|
|
351
|
+
lines.append(line)
|
|
352
|
+
except ValueError:
|
|
353
|
+
if lines:
|
|
354
|
+
lines.append(line)
|
|
355
|
+
return "\n".join(lines)
|
|
356
|
+
|
|
338
357
|
socket_path = get_socket_path(session_name)
|
|
339
|
-
for
|
|
358
|
+
for i in range(120):
|
|
340
359
|
await asyncio.sleep(0.1)
|
|
341
360
|
if socket_path.exists():
|
|
342
361
|
break
|
|
362
|
+
if (i + 1) % 10 == 0:
|
|
363
|
+
elapsed = (i + 1) // 10
|
|
364
|
+
rc = proc.poll()
|
|
365
|
+
if rc is not None:
|
|
366
|
+
log_content = _read_log_since(start_time)
|
|
367
|
+
logger.warning(f"会话启动失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
|
|
368
|
+
await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
|
|
369
|
+
return
|
|
370
|
+
logger.info(f"等待 server socket... ({elapsed}s)")
|
|
343
371
|
else:
|
|
344
|
-
|
|
372
|
+
log_content = _read_log_since(start_time)
|
|
373
|
+
logger.error(f"会话启动超时 (12s), session={session_name}\n{log_content}")
|
|
374
|
+
await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
|
|
345
375
|
return
|
|
346
376
|
|
|
347
377
|
ok = await self._attach(chat_id, session_name)
|
package/lark_client/main.py
CHANGED
|
@@ -12,16 +12,60 @@ import signal
|
|
|
12
12
|
import sys
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
+
|
|
16
|
+
# 设置 sys.path 以导入 utils 模块
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
18
|
+
from utils.session import USER_DATA_DIR
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _setup_logging():
|
|
22
|
+
"""配置 lark_client 日志:INFO → lark_client.log, DEBUG → lark_client.debug.log"""
|
|
23
|
+
from .config import LARK_LOG_LEVEL
|
|
24
|
+
|
|
25
|
+
log_dir = USER_DATA_DIR
|
|
26
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
# 日志格式(含毫秒级时间戳)
|
|
29
|
+
log_format = "%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s"
|
|
30
|
+
date_format = "%Y-%m-%d %H:%M:%S"
|
|
31
|
+
formatter = logging.Formatter(log_format, datefmt=date_format)
|
|
32
|
+
|
|
33
|
+
# 根 logger 配置
|
|
34
|
+
root_logger = logging.getLogger()
|
|
35
|
+
root_logger.setLevel(LARK_LOG_LEVEL)
|
|
36
|
+
|
|
37
|
+
# 清除默认 handler
|
|
38
|
+
root_logger.handlers.clear()
|
|
39
|
+
|
|
40
|
+
# 正常日志文件(INFO 及以上)
|
|
41
|
+
info_handler = logging.FileHandler(log_dir / "lark_client.log", encoding="utf-8")
|
|
42
|
+
info_handler.setLevel(logging.INFO)
|
|
43
|
+
info_handler.setFormatter(formatter)
|
|
44
|
+
root_logger.addHandler(info_handler)
|
|
45
|
+
|
|
46
|
+
# 调试日志文件(DEBUG 及以上,仅当 LARK_LOG_LEVEL=DEBUG 时写入)
|
|
47
|
+
if LARK_LOG_LEVEL == logging.DEBUG:
|
|
48
|
+
debug_handler = logging.FileHandler(log_dir / "lark_client.debug.log", encoding="utf-8")
|
|
49
|
+
debug_handler.setLevel(logging.DEBUG)
|
|
50
|
+
debug_handler.setFormatter(formatter)
|
|
51
|
+
root_logger.addHandler(debug_handler)
|
|
52
|
+
|
|
53
|
+
# 第三方库保持 INFO 级别
|
|
54
|
+
for _noisy in ('urllib3', 'websockets', 'asyncio'):
|
|
55
|
+
logging.getLogger(_noisy).setLevel(logging.INFO)
|
|
56
|
+
|
|
57
|
+
# 控制台输出(仅重要消息,无调试信息)
|
|
58
|
+
console_handler = logging.StreamHandler()
|
|
59
|
+
console_handler.setLevel(logging.INFO)
|
|
60
|
+
console_handler.setFormatter(formatter)
|
|
61
|
+
root_logger.addHandler(console_handler)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# 在导入 lark SDK 之前配置日志
|
|
65
|
+
_setup_logging()
|
|
66
|
+
|
|
15
67
|
import lark_oapi as lark
|
|
16
68
|
|
|
17
|
-
# 在 SDK 配置 logging 之前,先设置根 logger 和我们自己模块的 DEBUG 级别
|
|
18
|
-
logging.basicConfig(
|
|
19
|
-
level=logging.DEBUG,
|
|
20
|
-
format='[%(name)s] %(message)s',
|
|
21
|
-
)
|
|
22
|
-
# 将噪音较大的第三方库保持 INFO 级别
|
|
23
|
-
for _noisy in ('urllib3', 'websockets', 'asyncio'):
|
|
24
|
-
logging.getLogger(_noisy).setLevel(logging.INFO)
|
|
25
69
|
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
|
|
26
70
|
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
|
27
71
|
P2CardActionTrigger, P2CardActionTriggerResponse, CallBackToast
|
|
@@ -11,7 +11,7 @@ import sys
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Optional, Callable, Dict
|
|
13
13
|
|
|
14
|
-
logging.basicConfig(level=logging.
|
|
14
|
+
logging.basicConfig(level=logging.INFO, format='[%(name)s] %(message)s')
|
|
15
15
|
logger = logging.getLogger('SessionBridge')
|
|
16
16
|
|
|
17
17
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-claude",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "双端共享 Claude CLI 工具",
|
|
5
5
|
"bin": {
|
|
6
6
|
"remote-claude": "bin/remote-claude",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
36
|
-
"url": "git+https://github.com/
|
|
36
|
+
"url": "git+https://github.com/yyzybb537/remote_claude.git"
|
|
37
37
|
},
|
|
38
38
|
"keywords": [
|
|
39
39
|
"claude",
|
package/remote_claude.py
CHANGED
|
@@ -3,19 +3,24 @@
|
|
|
3
3
|
Remote Claude - 双端共享 Claude CLI 工具
|
|
4
4
|
|
|
5
5
|
命令:
|
|
6
|
-
start <name>
|
|
7
|
-
attach <name>
|
|
8
|
-
list
|
|
9
|
-
kill <name>
|
|
10
|
-
|
|
6
|
+
start <name> 启动新会话(在 tmux 中)
|
|
7
|
+
attach <name> 连接到已有会话
|
|
8
|
+
list 列出所有会话
|
|
9
|
+
kill <name> 终止会话
|
|
10
|
+
status <name> 显示会话状态
|
|
11
|
+
lark 飞书客户端管理(start/stop/restart/status)
|
|
12
|
+
stats 查看使用统计
|
|
13
|
+
update 更新 remote-claude 到最新版本
|
|
11
14
|
"""
|
|
12
15
|
|
|
13
16
|
import argparse
|
|
17
|
+
import logging
|
|
14
18
|
import os
|
|
15
19
|
import sys
|
|
16
20
|
import subprocess
|
|
17
21
|
import time
|
|
18
22
|
import signal
|
|
23
|
+
from datetime import datetime
|
|
19
24
|
from pathlib import Path
|
|
20
25
|
|
|
21
26
|
# 确保项目根目录在 sys.path 中,以便 import client / server 子模块
|
|
@@ -31,7 +36,7 @@ from utils.session import (
|
|
|
31
36
|
is_lark_running, get_lark_pid, get_lark_status, get_lark_pid_file,
|
|
32
37
|
save_lark_status, cleanup_lark,
|
|
33
38
|
USER_DATA_DIR, ensure_user_data_dir, get_lark_log_file,
|
|
34
|
-
get_env_snapshot_path
|
|
39
|
+
get_env_snapshot_path,
|
|
35
40
|
)
|
|
36
41
|
|
|
37
42
|
|
|
@@ -91,7 +96,22 @@ def cmd_start(args):
|
|
|
91
96
|
|
|
92
97
|
server_cmd = f"{env_prefix}uv run --project '{SCRIPT_DIR}' python3 '{server_script}'{debug_flag}{debug_verbose_flag}{cli_type_flag} -- '{session_name}' {claude_args_str}"
|
|
93
98
|
|
|
94
|
-
|
|
99
|
+
# 配置启动日志(写文件 + stdout)
|
|
100
|
+
_log_path = USER_DATA_DIR / "startup.log"
|
|
101
|
+
_start_logger = logging.getLogger('Start')
|
|
102
|
+
if not _start_logger.handlers:
|
|
103
|
+
_handler_file = logging.FileHandler(_log_path, encoding="utf-8")
|
|
104
|
+
_handler_file.setFormatter(logging.Formatter(
|
|
105
|
+
"%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
|
|
106
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
107
|
+
))
|
|
108
|
+
_start_logger.addHandler(_handler_file)
|
|
109
|
+
_start_logger.setLevel(logging.INFO)
|
|
110
|
+
_start_logger.propagate = False
|
|
111
|
+
|
|
112
|
+
start_time = datetime.now()
|
|
113
|
+
_start_logger.info(f"启动会话: {session_name}")
|
|
114
|
+
_start_logger.info(f"server_cmd: {server_cmd}")
|
|
95
115
|
|
|
96
116
|
# 创建 tmux 会话,运行 server(detached,仅后台)
|
|
97
117
|
if not tmux_create_session(session_name, server_cmd, detached=True):
|
|
@@ -100,12 +120,30 @@ def cmd_start(args):
|
|
|
100
120
|
|
|
101
121
|
# 等待 server 启动
|
|
102
122
|
socket_path = get_socket_path(session_name)
|
|
103
|
-
for
|
|
123
|
+
for i in range(50): # 最多等待 5 秒
|
|
104
124
|
if socket_path.exists():
|
|
105
125
|
break
|
|
106
126
|
time.sleep(0.1)
|
|
127
|
+
if (i + 1) % 10 == 0:
|
|
128
|
+
elapsed = (i + 1) // 10
|
|
129
|
+
print(f"等待 Server 启动... ({elapsed}s)")
|
|
107
130
|
else:
|
|
108
|
-
print("错误: Server 启动超时")
|
|
131
|
+
print("错误: Server 启动超时 (5s)")
|
|
132
|
+
# 过滤出本次启动后的日志行
|
|
133
|
+
if _log_path.exists():
|
|
134
|
+
lines = []
|
|
135
|
+
for line in _log_path.read_text(encoding="utf-8").splitlines():
|
|
136
|
+
try:
|
|
137
|
+
ts = datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
|
|
138
|
+
if ts >= start_time:
|
|
139
|
+
lines.append(line)
|
|
140
|
+
except ValueError:
|
|
141
|
+
if lines: # 多行日志的续行,附到上一条
|
|
142
|
+
lines.append(line)
|
|
143
|
+
if lines:
|
|
144
|
+
print(f"--- Server 日志 ({_log_path}) ---")
|
|
145
|
+
print("\n".join(lines))
|
|
146
|
+
print("-------------------")
|
|
109
147
|
tmux_kill_session(session_name)
|
|
110
148
|
return 1
|
|
111
149
|
|
|
@@ -148,16 +186,29 @@ def cmd_list(args):
|
|
|
148
186
|
print("没有活跃的会话")
|
|
149
187
|
return 0
|
|
150
188
|
|
|
189
|
+
# ANSI 颜色码
|
|
190
|
+
YELLOW = "\033[33m"
|
|
191
|
+
GREEN = "\033[32m"
|
|
192
|
+
RESET = "\033[0m"
|
|
193
|
+
|
|
151
194
|
print("活跃会话:")
|
|
152
|
-
print("-" *
|
|
153
|
-
print(f"{'
|
|
154
|
-
print("-" *
|
|
195
|
+
print("-" * 50)
|
|
196
|
+
print(f"{'类型':<8} {'PID':<10} {'tmux':<10} {'名称'}")
|
|
197
|
+
print("-" * 50)
|
|
155
198
|
|
|
156
199
|
for s in sessions:
|
|
157
200
|
tmux_status = "是" if s["tmux"] else "否"
|
|
158
|
-
|
|
201
|
+
cli_type = s.get('cli_type', 'claude')
|
|
202
|
+
# 根据类型选择颜色
|
|
203
|
+
if cli_type == 'codex':
|
|
204
|
+
cli_colored = f"{GREEN}{cli_type}{RESET}"
|
|
205
|
+
else:
|
|
206
|
+
cli_colored = f"{YELLOW}{cli_type}{RESET}"
|
|
207
|
+
# 带颜色的字段需要单独计算宽度
|
|
208
|
+
padding = " " * (8 - len(cli_type))
|
|
209
|
+
print(f"{cli_colored}{padding} {s['pid']:<10} {tmux_status:<10} {s['name']}")
|
|
159
210
|
|
|
160
|
-
print("-" *
|
|
211
|
+
print("-" * 50)
|
|
161
212
|
print(f"共 {len(sessions)} 个会话")
|
|
162
213
|
|
|
163
214
|
return 0
|
|
@@ -448,9 +499,11 @@ def main():
|
|
|
448
499
|
epilog="""
|
|
449
500
|
示例:
|
|
450
501
|
%(prog)s start mywork 启动名为 mywork 的会话
|
|
502
|
+
%(prog)s start mywork --cli codex 启动 codex 会话
|
|
451
503
|
%(prog)s attach mywork 连接到 mywork 会话
|
|
452
504
|
%(prog)s list 列出所有会话
|
|
453
505
|
%(prog)s kill mywork 终止 mywork 会话
|
|
506
|
+
%(prog)s status mywork 显示 mywork 会话状态
|
|
454
507
|
|
|
455
508
|
飞书客户端:
|
|
456
509
|
%(prog)s lark start 启动飞书客户端
|
|
@@ -473,6 +526,9 @@ def main():
|
|
|
473
526
|
%(prog)s stats --detail 详细分类
|
|
474
527
|
%(prog)s stats --session mywork 按会话筛选
|
|
475
528
|
%(prog)s stats --reset 清空数据
|
|
529
|
+
|
|
530
|
+
更新:
|
|
531
|
+
%(prog)s update 更新到最新版本
|
|
476
532
|
"""
|
|
477
533
|
)
|
|
478
534
|
|
|
@@ -580,12 +636,16 @@ def main():
|
|
|
580
636
|
update_parser = subparsers.add_parser("update", help="更新 remote-claude 到最新版本")
|
|
581
637
|
update_parser.set_defaults(func=cmd_update)
|
|
582
638
|
|
|
583
|
-
args = parser.
|
|
639
|
+
args, remaining = parser.parse_known_args()
|
|
584
640
|
|
|
585
641
|
if args.command is None:
|
|
586
642
|
parser.print_help()
|
|
587
643
|
return 0
|
|
588
644
|
|
|
645
|
+
# 将剩余参数合并到 claude_args(支持 cx/cdx 脚本中使用 -- 分隔符)
|
|
646
|
+
if args.command == "start" and hasattr(args, 'claude_args'):
|
|
647
|
+
args.claude_args = args.claude_args + remaining
|
|
648
|
+
|
|
589
649
|
return args.func(args)
|
|
590
650
|
|
|
591
651
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Remote Claude Server Package
|
package/server/server.py
CHANGED
|
@@ -9,6 +9,7 @@ Proxy Server
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
+
import logging
|
|
12
13
|
import os
|
|
13
14
|
import pty
|
|
14
15
|
import signal
|
|
@@ -35,9 +36,12 @@ from utils.protocol import (
|
|
|
35
36
|
)
|
|
36
37
|
from utils.session import (
|
|
37
38
|
get_socket_path, get_pid_file, ensure_socket_dir,
|
|
38
|
-
generate_client_id, cleanup_session, _safe_filename, get_env_file
|
|
39
|
+
generate_client_id, cleanup_session, _safe_filename, get_env_file,
|
|
40
|
+
SOCKET_DIR
|
|
39
41
|
)
|
|
40
42
|
|
|
43
|
+
logger = logging.getLogger('Server')
|
|
44
|
+
|
|
41
45
|
# 加载用户 .env 配置(支持 CLAUDE_COMMAND 等)
|
|
42
46
|
try:
|
|
43
47
|
from dotenv import load_dotenv
|
|
@@ -64,6 +68,11 @@ class _FrameObs:
|
|
|
64
68
|
status_line: Optional[object] # 本帧的 StatusLine(None=无)
|
|
65
69
|
block_blink: bool = False # 本帧最后一个 OutputBlock 是否 is_streaming=True
|
|
66
70
|
has_background_agents: bool = False # 底部栏是否有后台 agent 信息
|
|
71
|
+
# 用于字符变化检测(增强闪烁判断)
|
|
72
|
+
last_ob_start_row: int = -1 # 最后 OutputBlock 的起始行号(跨帧识别同一 block)
|
|
73
|
+
last_ob_indicator_char: str = '' # 指示符字符值(pyte char.data)
|
|
74
|
+
last_ob_indicator_fg: str = '' # 指示符前景色(pyte char.fg)
|
|
75
|
+
last_ob_indicator_bold: bool = False # 指示符 bold 属性(影响显示亮度)
|
|
67
76
|
|
|
68
77
|
|
|
69
78
|
@dataclass
|
|
@@ -154,6 +163,9 @@ class OutputWatcher:
|
|
|
154
163
|
|
|
155
164
|
def feed(self, data: bytes):
|
|
156
165
|
self._renderer.feed(data) # 直接喂持久化 screen,不再缓存原始字节
|
|
166
|
+
# 诊断日志:记录 PTY 数据到达
|
|
167
|
+
if data:
|
|
168
|
+
logger.debug(f"[diag-feed] len={len(data)} data={data[:50]!r}")
|
|
157
169
|
try:
|
|
158
170
|
loop = asyncio.get_running_loop()
|
|
159
171
|
except RuntimeError:
|
|
@@ -185,6 +197,8 @@ class OutputWatcher:
|
|
|
185
197
|
|
|
186
198
|
async def _flush(self):
|
|
187
199
|
self._pending = False
|
|
200
|
+
# 诊断日志:记录 flush 触发时间和帧窗口大小
|
|
201
|
+
logger.debug(f"[diag-flush] ts={time.time():.6f} window_size={len(self._frame_window)}")
|
|
188
202
|
try:
|
|
189
203
|
from utils.components import StatusLine, BottomBar, Divider, OutputBlock, AgentPanelBlock, OptionBlock
|
|
190
204
|
|
|
@@ -224,10 +238,24 @@ class OutputWatcher:
|
|
|
224
238
|
# 4a. 记录原始帧观测(必须用未平滑的原始值)
|
|
225
239
|
last_ob_blink = False
|
|
226
240
|
last_ob_content = ''
|
|
241
|
+
last_ob_start_row = -1
|
|
242
|
+
last_ob_indicator_char = ''
|
|
243
|
+
last_ob_indicator_fg = ''
|
|
244
|
+
last_ob_indicator_bold = False
|
|
227
245
|
for b in reversed(visible_blocks):
|
|
228
246
|
if isinstance(b, OutputBlock):
|
|
229
247
|
last_ob_blink = b.is_streaming
|
|
230
248
|
last_ob_content = b.content[:40]
|
|
249
|
+
last_ob_start_row = b.start_row
|
|
250
|
+
# 直接读 pyte screen buffer 获取原始字符属性(用于变化检测)
|
|
251
|
+
if b.start_row >= 0:
|
|
252
|
+
try:
|
|
253
|
+
char = self._renderer.screen.buffer[b.start_row][0]
|
|
254
|
+
last_ob_indicator_char = str(getattr(char, 'data', ''))
|
|
255
|
+
last_ob_indicator_fg = str(getattr(char, 'fg', ''))
|
|
256
|
+
last_ob_indicator_bold = bool(getattr(char, 'bold', False))
|
|
257
|
+
except (KeyError, IndexError):
|
|
258
|
+
pass
|
|
231
259
|
break
|
|
232
260
|
if last_ob_blink:
|
|
233
261
|
_blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
|
|
@@ -241,6 +269,10 @@ class OutputWatcher:
|
|
|
241
269
|
status_line=raw_status_line,
|
|
242
270
|
block_blink=last_ob_blink,
|
|
243
271
|
has_background_agents=getattr(raw_bottom_bar, 'has_background_agents', False) if raw_bottom_bar else False,
|
|
272
|
+
last_ob_start_row=last_ob_start_row,
|
|
273
|
+
last_ob_indicator_char=last_ob_indicator_char,
|
|
274
|
+
last_ob_indicator_fg=last_ob_indicator_fg,
|
|
275
|
+
last_ob_indicator_bold=last_ob_indicator_bold,
|
|
244
276
|
))
|
|
245
277
|
cutoff = now - self.WINDOW_SECONDS
|
|
246
278
|
while self._frame_window and self._frame_window[0].ts < cutoff:
|
|
@@ -262,9 +294,27 @@ class OutputWatcher:
|
|
|
262
294
|
None
|
|
263
295
|
)
|
|
264
296
|
|
|
265
|
-
# 4c. block blink
|
|
266
|
-
#
|
|
297
|
+
# 4c. block blink 平滑:两种触发路径
|
|
298
|
+
# 路径1:窗口内任意帧有 pyte blink 属性
|
|
267
299
|
window_block_active = any(o.block_blink for o in window_list)
|
|
300
|
+
|
|
301
|
+
# 路径2:窗口内同一 block 的指示符字符值/颜色/bold 有变化(增强闪烁判断)
|
|
302
|
+
if not window_block_active and last_ob_start_row >= 0:
|
|
303
|
+
same_block = [o for o in window_list if o.last_ob_start_row == last_ob_start_row]
|
|
304
|
+
if len(same_block) >= 2:
|
|
305
|
+
chars = {o.last_ob_indicator_char for o in same_block if o.last_ob_indicator_char}
|
|
306
|
+
fgs = {o.last_ob_indicator_fg for o in same_block if o.last_ob_indicator_fg}
|
|
307
|
+
bolds = {o.last_ob_indicator_bold for o in same_block}
|
|
308
|
+
if len(chars) > 1 or len(fgs) > 1 or len(bolds) > 1:
|
|
309
|
+
window_block_active = True
|
|
310
|
+
# 记录字符变化触发原因
|
|
311
|
+
_blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
|
|
312
|
+
_blink_log.write(
|
|
313
|
+
f"[{time.strftime('%H:%M:%S')}] char-change row={last_ob_start_row}"
|
|
314
|
+
f" chars={chars} fgs={fgs} bolds={bolds}\n"
|
|
315
|
+
)
|
|
316
|
+
_blink_log.close()
|
|
317
|
+
|
|
268
318
|
if window_block_active:
|
|
269
319
|
for b in reversed(visible_blocks):
|
|
270
320
|
if isinstance(b, OutputBlock):
|
|
@@ -303,6 +353,16 @@ class OutputWatcher:
|
|
|
303
353
|
layout_mode=self._parser.last_layout_mode,
|
|
304
354
|
cli_type=self._cli_type,
|
|
305
355
|
)
|
|
356
|
+
# 诊断日志:检测最终输出中是否有同时存在 status_line 和 SystemBlock 的情况
|
|
357
|
+
if display_status:
|
|
358
|
+
status_prefix = display_status.raw[:30] if hasattr(display_status, 'raw') else str(display_status)[:30]
|
|
359
|
+
has_systemblock_with_status = any(
|
|
360
|
+
b.__class__.__name__ == 'SystemBlock' and
|
|
361
|
+
hasattr(b, 'content') and status_prefix in b.content
|
|
362
|
+
for b in all_blocks
|
|
363
|
+
)
|
|
364
|
+
if has_systemblock_with_status:
|
|
365
|
+
logger.debug(f"[diag-output] BOTH status_line and SystemBlock present! status_line={status_prefix!r}")
|
|
306
366
|
self.last_window = window
|
|
307
367
|
|
|
308
368
|
# 7. 输出
|
|
@@ -342,7 +402,7 @@ class OutputWatcher:
|
|
|
342
402
|
lines.append(f" ansi_raw={sl.ansi_raw[:120]!r}")
|
|
343
403
|
lines.append(f" ansi_render: {sl.ansi_indicator} {sl.ansi_raw[:120]}\x1b[0m")
|
|
344
404
|
else:
|
|
345
|
-
lines.append(f"status_line: {sl.
|
|
405
|
+
lines.append(f"status_line: {sl.ansi_raw[:120]}\x1b[0m")
|
|
346
406
|
else:
|
|
347
407
|
lines.append("status_line: None")
|
|
348
408
|
# BottomBar
|
|
@@ -529,7 +589,7 @@ class ClientConnection:
|
|
|
529
589
|
self.writer.write(data)
|
|
530
590
|
await self.writer.drain()
|
|
531
591
|
except Exception as e:
|
|
532
|
-
|
|
592
|
+
logger.warning(f"发送消息失败 ({self.client_id}): {e}")
|
|
533
593
|
|
|
534
594
|
async def read_message(self) -> Optional[Message]:
|
|
535
595
|
"""读取一条消息"""
|
|
@@ -540,7 +600,7 @@ class ClientConnection:
|
|
|
540
600
|
try:
|
|
541
601
|
return decode_message(line)
|
|
542
602
|
except Exception as e:
|
|
543
|
-
|
|
603
|
+
logger.warning(f"解析消息失败: {e}")
|
|
544
604
|
continue
|
|
545
605
|
|
|
546
606
|
# 读取更多数据
|
|
@@ -608,6 +668,8 @@ class ProxyServer:
|
|
|
608
668
|
|
|
609
669
|
async def start(self):
|
|
610
670
|
"""启动服务器"""
|
|
671
|
+
t0 = time.time()
|
|
672
|
+
logger.info(f"正在启动 (session={self.session_name})")
|
|
611
673
|
ensure_socket_dir()
|
|
612
674
|
|
|
613
675
|
# 清理旧的 socket 文件
|
|
@@ -615,12 +677,15 @@ class ProxyServer:
|
|
|
615
677
|
self.socket_path.unlink()
|
|
616
678
|
|
|
617
679
|
# 启动 PTY
|
|
680
|
+
t1 = time.time()
|
|
618
681
|
self._start_pty()
|
|
682
|
+
logger.info(f"PTY 已启动 ({(time.time()-t1)*1000:.0f}ms)")
|
|
619
683
|
|
|
620
684
|
# 写入 PID 文件
|
|
621
685
|
self.pid_file.write_text(str(os.getpid()))
|
|
622
686
|
|
|
623
687
|
# 启动 Unix Socket 服务器
|
|
688
|
+
t2 = time.time()
|
|
624
689
|
self.server = await asyncio.start_unix_server(
|
|
625
690
|
self._handle_client,
|
|
626
691
|
path=str(self.socket_path)
|
|
@@ -628,11 +693,14 @@ class ProxyServer:
|
|
|
628
693
|
|
|
629
694
|
self.running = True
|
|
630
695
|
_track_stats('session', 'start', session_name=self.session_name)
|
|
631
|
-
|
|
696
|
+
logger.info(f"已启动: {self.socket_path} (Socket {(time.time()-t2)*1000:.0f}ms, 总计 {(time.time()-t0)*1000:.0f}ms)")
|
|
632
697
|
|
|
633
698
|
# 启动 PTY 读取任务
|
|
634
699
|
asyncio.create_task(self._read_pty())
|
|
635
700
|
|
|
701
|
+
# 切换到运行阶段日志
|
|
702
|
+
self._switch_to_runtime_logging()
|
|
703
|
+
|
|
636
704
|
# 等待服务器关闭
|
|
637
705
|
async with self.server:
|
|
638
706
|
await self.server.serve_forever()
|
|
@@ -648,6 +716,32 @@ class ProxyServer:
|
|
|
648
716
|
return CodexParser()
|
|
649
717
|
return ClaudeParser()
|
|
650
718
|
|
|
719
|
+
def _switch_to_runtime_logging(self):
|
|
720
|
+
"""从启动日志切换到运行阶段日志"""
|
|
721
|
+
root_logger = logging.getLogger()
|
|
722
|
+
|
|
723
|
+
# 移除启动日志 handler(保留 stdout handler)
|
|
724
|
+
for handler in root_logger.handlers[:]:
|
|
725
|
+
if isinstance(handler, logging.FileHandler) and \
|
|
726
|
+
not hasattr(handler, '_runtime_handler') and \
|
|
727
|
+
not hasattr(handler, '_debug_handler'):
|
|
728
|
+
root_logger.removeHandler(handler)
|
|
729
|
+
|
|
730
|
+
# 添加运行阶段日志文件
|
|
731
|
+
safe_name = _safe_filename(self.session_name)
|
|
732
|
+
runtime_handler = logging.FileHandler(
|
|
733
|
+
f"{SOCKET_DIR}/{safe_name}_server.log",
|
|
734
|
+
encoding="utf-8"
|
|
735
|
+
)
|
|
736
|
+
runtime_handler.setFormatter(logging.Formatter(
|
|
737
|
+
"%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
|
|
738
|
+
datefmt="%Y-%m-%d %H:%M:%S"
|
|
739
|
+
))
|
|
740
|
+
runtime_handler._runtime_handler = True # 标记,方便后续清理
|
|
741
|
+
root_logger.addHandler(runtime_handler)
|
|
742
|
+
|
|
743
|
+
logger.info(f"日志已切换到运行阶段: {safe_name}_server.log")
|
|
744
|
+
|
|
651
745
|
def _get_effective_cmd(self) -> str:
|
|
652
746
|
"""根据 cli_type 返回实际执行的命令(codex 时使用 'codex',否则用 claude_cmd)"""
|
|
653
747
|
if self.cli_type == "codex":
|
|
@@ -664,8 +758,14 @@ class ProxyServer:
|
|
|
664
758
|
try:
|
|
665
759
|
with open(env_snapshot_path) as _f:
|
|
666
760
|
_extra_env = _json.load(_f)
|
|
761
|
+
logger.info(f"环境快照已加载 ({len(_extra_env)} 个变量)")
|
|
667
762
|
except Exception:
|
|
668
|
-
|
|
763
|
+
logger.warning("环境快照加载失败,使用当前进程环境")
|
|
764
|
+
|
|
765
|
+
# 提前计算命令(fork 后父子进程共享,方便父进程打印和子进程执行)
|
|
766
|
+
import shlex as _shlex
|
|
767
|
+
_cmd_parts = _shlex.split(self._get_effective_cmd())
|
|
768
|
+
_full_cmd = ' '.join(_cmd_parts + self.claude_args)
|
|
669
769
|
|
|
670
770
|
try:
|
|
671
771
|
pid, fd = pty.fork()
|
|
@@ -688,10 +788,25 @@ class ProxyServer:
|
|
|
688
788
|
# 清除 tmux 标识变量(PTY 数据不经过 tmux,不应让 Claude CLI 误判终端环境)
|
|
689
789
|
child_env.pop('TMUX', None)
|
|
690
790
|
child_env.pop('TMUX_PANE', None)
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
791
|
+
try:
|
|
792
|
+
os.execvpe(_cmd_parts[0], _cmd_parts + self.claude_args, child_env)
|
|
793
|
+
except Exception as _e:
|
|
794
|
+
msg = f"启动失败: 命令 '{_cmd_parts[0]}' 无法执行: {_e}"
|
|
795
|
+
os.write(1, (msg + "\n").encode()) # 写到 PTY
|
|
796
|
+
# fork 后不能安全使用 logging,直接追加写日志文件
|
|
797
|
+
try:
|
|
798
|
+
import time as _t
|
|
799
|
+
_ts = _t.strftime("%Y-%m-%d %H:%M:%S")
|
|
800
|
+
_ms = int((_t.time() % 1) * 1000)
|
|
801
|
+
_log_line = f"{_ts}.{_ms:03d} [Server] ERROR {msg}\n"
|
|
802
|
+
_home = os.path.expanduser("~")
|
|
803
|
+
_log_file = os.path.join(_home, ".remote-claude", "startup.log")
|
|
804
|
+
with open(_log_file, "a", encoding="utf-8") as _f:
|
|
805
|
+
_f.write(_log_line)
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
os._exit(127) # 127 = command not found (shell convention)
|
|
809
|
+
os._exit(1) # 理论上不可达
|
|
695
810
|
else:
|
|
696
811
|
# 父进程
|
|
697
812
|
self.master_fd = fd
|
|
@@ -706,7 +821,8 @@ class ProxyServer:
|
|
|
706
821
|
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
707
822
|
|
|
708
823
|
cli_label = self.cli_type.capitalize()
|
|
709
|
-
|
|
824
|
+
logger.info(f"启动命令: {_full_cmd}")
|
|
825
|
+
logger.info(f"{cli_label} 已启动 (PID: {pid}, PTY: {self.PTY_COLS}×{self.PTY_ROWS})")
|
|
710
826
|
|
|
711
827
|
_COALESCE_MAX = 64 * 1024 # 64KB,防止单次广播过大
|
|
712
828
|
|
|
@@ -744,11 +860,19 @@ class ProxyServer:
|
|
|
744
860
|
break
|
|
745
861
|
except Exception as e:
|
|
746
862
|
if self.running:
|
|
747
|
-
|
|
863
|
+
logger.error(f"读取 PTY 错误: {e}")
|
|
748
864
|
break
|
|
749
865
|
|
|
750
|
-
# Claude
|
|
751
|
-
|
|
866
|
+
# Claude 退出,获取 exit code 以便诊断
|
|
867
|
+
try:
|
|
868
|
+
_, status = os.waitpid(self.child_pid, os.WNOHANG)
|
|
869
|
+
if status != 0:
|
|
870
|
+
exit_code = os.waitstatus_to_exitcode(status)
|
|
871
|
+
logger.error(f"CLI 进程异常退出 (exit_code={exit_code})")
|
|
872
|
+
else:
|
|
873
|
+
logger.info("Claude 已退出")
|
|
874
|
+
except Exception:
|
|
875
|
+
logger.info("Claude 已退出")
|
|
752
876
|
await self._shutdown()
|
|
753
877
|
|
|
754
878
|
def _read_pty_sync(self) -> Optional[bytes]:
|
|
@@ -766,7 +890,7 @@ class ProxyServer:
|
|
|
766
890
|
client = ClientConnection(client_id, reader, writer)
|
|
767
891
|
self.clients[client_id] = client
|
|
768
892
|
|
|
769
|
-
|
|
893
|
+
logger.info(f"客户端连接: {client_id}")
|
|
770
894
|
_track_stats('session', 'attach', session_name=self.session_name)
|
|
771
895
|
|
|
772
896
|
# 发送历史输出
|
|
@@ -782,12 +906,12 @@ class ProxyServer:
|
|
|
782
906
|
break
|
|
783
907
|
await self._handle_message(client_id, msg)
|
|
784
908
|
except Exception as e:
|
|
785
|
-
|
|
909
|
+
logger.error(f"客户端处理错误 ({client_id}): {e}")
|
|
786
910
|
finally:
|
|
787
911
|
# 清理
|
|
788
912
|
del self.clients[client_id]
|
|
789
913
|
client.close()
|
|
790
|
-
|
|
914
|
+
logger.info(f"客户端断开: {client_id}")
|
|
791
915
|
|
|
792
916
|
async def _handle_message(self, client_id: str, msg: Message):
|
|
793
917
|
"""处理客户端消息"""
|
|
@@ -804,7 +928,7 @@ class ProxyServer:
|
|
|
804
928
|
_track_stats('terminal', 'input', session_name=self.session_name,
|
|
805
929
|
value=len(data))
|
|
806
930
|
except Exception as e:
|
|
807
|
-
|
|
931
|
+
logger.error(f"写入 PTY 错误: {e}")
|
|
808
932
|
|
|
809
933
|
# 广播输入给其他客户端(飞书侧可以感知终端用户的输入内容)
|
|
810
934
|
for cid, client in list(self.clients.items()):
|
|
@@ -824,7 +948,7 @@ class ProxyServer:
|
|
|
824
948
|
winsize = struct.pack('HHHH', msg.rows, msg.cols, 0, 0)
|
|
825
949
|
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
|
|
826
950
|
except Exception as e:
|
|
827
|
-
|
|
951
|
+
logger.error(f"调整终端大小错误: {e}")
|
|
828
952
|
|
|
829
953
|
async def _broadcast_output(self, data: bytes):
|
|
830
954
|
"""广播输出给所有客户端,同时喂给 OutputWatcher 生成快照"""
|
|
@@ -863,7 +987,7 @@ class ProxyServer:
|
|
|
863
987
|
# 清理文件
|
|
864
988
|
cleanup_session(self.session_name)
|
|
865
989
|
|
|
866
|
-
|
|
990
|
+
logger.info("已关闭")
|
|
867
991
|
|
|
868
992
|
|
|
869
993
|
def run_server(session_name: str, claude_args: list = None,
|
|
@@ -903,7 +1027,29 @@ if __name__ == "__main__":
|
|
|
903
1027
|
help="debug 日志输出完整诊断信息(indicator、repr 等)")
|
|
904
1028
|
args = parser.parse_args()
|
|
905
1029
|
|
|
1030
|
+
# 配置日志:启动阶段输出到 stdout + startup.log
|
|
1031
|
+
from utils.session import USER_DATA_DIR, _safe_filename
|
|
1032
|
+
USER_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
1033
|
+
|
|
1034
|
+
# 先配置基本输出(stdout)
|
|
1035
|
+
logging.basicConfig(
|
|
1036
|
+
level=logging.INFO,
|
|
1037
|
+
format="%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
|
|
1038
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
1039
|
+
handlers=[logging.StreamHandler(sys.stdout)],
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
# 添加启动日志 handler
|
|
1043
|
+
startup_handler = logging.FileHandler(USER_DATA_DIR / "startup.log", encoding="utf-8")
|
|
1044
|
+
startup_handler.setFormatter(logging.Formatter(
|
|
1045
|
+
"%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
|
|
1046
|
+
datefmt="%Y-%m-%d %H:%M:%S"
|
|
1047
|
+
))
|
|
1048
|
+
startup_handler._startup_handler = True # 标记为启动日志 handler
|
|
1049
|
+
logging.getLogger().addHandler(startup_handler)
|
|
1050
|
+
|
|
906
1051
|
claude_cmd = os.environ.get("CLAUDE_COMMAND", "claude")
|
|
1052
|
+
logger.info(f"CLAUDE_COMMAND={claude_cmd!r}")
|
|
907
1053
|
run_server(args.session_name, args.claude_args, claude_cmd=claude_cmd,
|
|
908
1054
|
cli_type=args.cli_type,
|
|
909
1055
|
debug_screen=args.debug_screen, debug_verbose=args.debug_verbose)
|
package/server/shared_state.py
CHANGED
|
@@ -93,6 +93,7 @@ class SharedStateWriter:
|
|
|
93
93
|
"""写端:Server 进程持有,生命周期与 ProxyServer 相同"""
|
|
94
94
|
|
|
95
95
|
def __init__(self, session_name: str):
|
|
96
|
+
self._session_name = session_name
|
|
96
97
|
self._path = get_mq_path(session_name)
|
|
97
98
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
99
|
|
|
@@ -139,7 +140,7 @@ class SharedStateWriter:
|
|
|
139
140
|
"input_area_text": window.input_area_text,
|
|
140
141
|
"timestamp": window.timestamp,
|
|
141
142
|
"layout_mode": window.layout_mode,
|
|
142
|
-
"cli_type": getattr(window, "cli_type", "
|
|
143
|
+
"cli_type": getattr(window, "cli_type", "unknown"),
|
|
143
144
|
}
|
|
144
145
|
data = json.dumps(snapshot, ensure_ascii=False).encode('utf-8')
|
|
145
146
|
|
|
@@ -177,7 +178,7 @@ class SharedStateReader:
|
|
|
177
178
|
反映跨进程写入更新的问题。每次 read() 打开文件读取后关闭,保证读到最新数据。
|
|
178
179
|
"""
|
|
179
180
|
|
|
180
|
-
_EMPTY = {"blocks": [], "status_line": None, "bottom_bar": None, "option_block": None}
|
|
181
|
+
_EMPTY = {"blocks": [], "status_line": None, "bottom_bar": None, "option_block": None, "cli_type": "claude"}
|
|
181
182
|
|
|
182
183
|
def __init__(self, session_name: str):
|
|
183
184
|
self._path = get_mq_path(session_name)
|
package/utils/session.py
CHANGED
|
@@ -101,6 +101,8 @@ def tmux_create_session(session_name: str, command: str, detached: bool = True)
|
|
|
101
101
|
args.extend(["-x", "200", "-y", "50"]) # 默认大小
|
|
102
102
|
args.append(command)
|
|
103
103
|
|
|
104
|
+
import logging as _logging
|
|
105
|
+
_logging.getLogger('Start').info(f"tmux_cmd: {' '.join(args)}")
|
|
104
106
|
result = subprocess.run(args, capture_output=True)
|
|
105
107
|
if result.returncode == 0:
|
|
106
108
|
# 启用鼠标支持,允许在 tmux 窗口内用鼠标滚轮查看历史输出
|
|
@@ -221,10 +223,30 @@ def list_active_sessions() -> List[dict]:
|
|
|
221
223
|
import datetime
|
|
222
224
|
try:
|
|
223
225
|
mtime = pid_file.stat().st_mtime
|
|
224
|
-
start_time = datetime.datetime.fromtimestamp(mtime).strftime("%H:%M")
|
|
226
|
+
start_time = datetime.datetime.fromtimestamp(mtime).strftime("%m-%d %H:%M")
|
|
225
227
|
except OSError:
|
|
226
228
|
mtime = 0
|
|
227
229
|
start_time = "?"
|
|
230
|
+
|
|
231
|
+
# 读取 .mq 文件获取 cli_type(避免循环导入,在函数内导入)
|
|
232
|
+
try:
|
|
233
|
+
import sys
|
|
234
|
+
from pathlib import Path
|
|
235
|
+
import logging
|
|
236
|
+
project_root = str(Path(__file__).parent.parent)
|
|
237
|
+
if project_root not in sys.path:
|
|
238
|
+
sys.path.insert(0, project_root)
|
|
239
|
+
from server.shared_state import SharedStateReader
|
|
240
|
+
reader = SharedStateReader(session_name)
|
|
241
|
+
snapshot = reader.read()
|
|
242
|
+
cli_type = snapshot.get("cli_type", "claude")
|
|
243
|
+
except Exception as e:
|
|
244
|
+
# 添加详细日志记录,便于诊断问题
|
|
245
|
+
import logging
|
|
246
|
+
logger = logging.getLogger('Session')
|
|
247
|
+
logger.warning(f"读取共享内存 cli_type 失败: session={session_name}, error={e}")
|
|
248
|
+
cli_type = "claude" # 读取失败时使用默认值
|
|
249
|
+
|
|
228
250
|
sessions.append({
|
|
229
251
|
"name": session_name,
|
|
230
252
|
"socket": str(sock_file),
|
|
@@ -232,7 +254,8 @@ def list_active_sessions() -> List[dict]:
|
|
|
232
254
|
"cwd": cwd or "",
|
|
233
255
|
"start_time": start_time,
|
|
234
256
|
"mtime": mtime,
|
|
235
|
-
"tmux": tmux_session_exists(session_name)
|
|
257
|
+
"tmux": tmux_session_exists(session_name),
|
|
258
|
+
"cli_type": cli_type
|
|
236
259
|
})
|
|
237
260
|
except (ProcessLookupError, ValueError, OSError):
|
|
238
261
|
# 进程不存在或文件被并发清理,清理残留文件
|