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,198 @@
1
+ """
2
+ 会话共享状态 (mmap) — 全量快照架构
3
+
4
+ Server 进程将 ClaudeWindow 快照全量写入 mmap 文件,其他进程可通过
5
+ SharedStateReader 随时读取最新快照。
6
+
7
+ 设计原则:
8
+ - 每次 write_snapshot() 全量写入序列化后的 ClaudeWindow JSON
9
+ - 读端一次 read() 获取完整快照,无需逐条拼装
10
+ - 写入复杂度 O(blocks 总数),但避免了增量状态污染问题
11
+
12
+ 内存布局(200MB mmap 文件):
13
+ [Header 64B] @0 magic(4B) + version(4B) + snapshot_len(4B) + sequence(4B) + 保留
14
+ [Snapshot ~200MB] @64 JSON 序列化的 ClaudeWindow 快照
15
+
16
+ 文件路径:/tmp/remote-claude/<name>.mq
17
+ """
18
+
19
+ import json
20
+ import mmap
21
+ import struct
22
+ from dataclasses import asdict
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ # ── 布局常量 ──────────────────────────────────────────────────────────────────
27
+ MMAP_SIZE = 200 * 1024 * 1024 # 200MB
28
+ HEADER_SIZE = 64
29
+
30
+ HEADER_OFFSET = 0
31
+ COMPLETED_OFFSET = HEADER_SIZE # @64,快照数据起始
32
+
33
+ MAGIC = b'RCMQ'
34
+ VERSION = 2
35
+
36
+ # Header 内字段偏移
37
+ _H_MAGIC = 0 # 4B
38
+ _H_VERSION = 4 # 4B uint32
39
+ _H_SNAPSHOT_LEN = 8 # 4B uint32 — 快照 JSON 长度
40
+ _H_SEQUENCE = 12 # 4B uint32 — 写入序列号(单调递增)
41
+ # [16:64] 保留
42
+
43
+
44
+ def get_mq_path(session_name: str) -> Path:
45
+ """获取会话的共享状态文件路径"""
46
+ from utils.session import get_mq_path as _get_mq_path
47
+ return _get_mq_path(session_name)
48
+
49
+
50
+ def _component_to_dict(c) -> dict:
51
+ d = asdict(c)
52
+ d['_type'] = type(c).__name__
53
+ return d
54
+
55
+
56
+ def _block_id_from_dict(d: dict) -> str:
57
+ """从已序列化的 component dict 计算 block_id(与 server._block_id 逻辑一致)"""
58
+ t = d.get('_type', '')
59
+ if t == 'UserInput':
60
+ return f"U:{d.get('text', '')[:80]}"
61
+ elif t == 'OutputBlock':
62
+ content = d.get('content', '')
63
+ first_line = content.split('\n', 1)[0].strip()[:80]
64
+ return f"O:{first_line}"
65
+ elif t == 'OptionBlock':
66
+ sub = d.get('sub_type', 'option')
67
+ if sub == 'permission':
68
+ return f"P:{d.get('question', '')[:80]}"
69
+ return f"Q:{d.get('question', '')[:80]}"
70
+ elif t == 'PermissionBlock':
71
+ # 向后兼容旧数据
72
+ return f"P:{d.get('question', '')[:80]}"
73
+ elif t == 'AgentPanelBlock':
74
+ pt = d.get('panel_type', '')
75
+ if pt == 'detail':
76
+ return f"AP:{d.get('agent_name', '')[:80]}"
77
+ elif pt == 'summary':
78
+ return f"AP:summary:{d.get('agent_count', 0)}"
79
+ return f"AP:list:{d.get('agent_count', 0)}"
80
+ elif t == 'PlanBlock':
81
+ return f"PL:{d.get('title', '')[:80]}"
82
+ return ""
83
+
84
+
85
+ # ── Writer ────────────────────────────────────────────────────────────────────
86
+
87
+ class SharedStateWriter:
88
+ """写端:Server 进程持有,生命周期与 ProxyServer 相同"""
89
+
90
+ def __init__(self, session_name: str):
91
+ self._path = get_mq_path(session_name)
92
+ self._path.parent.mkdir(parents=True, exist_ok=True)
93
+
94
+ self._f = open(self._path, 'w+b')
95
+ self._f.truncate(MMAP_SIZE)
96
+ self._f.flush()
97
+ self._mm = mmap.mmap(self._f.fileno(), MMAP_SIZE)
98
+
99
+ # 写入 header 初始值(magic + version + 全零计数字段)
100
+ self._mm.seek(HEADER_OFFSET)
101
+ self._mm.write(MAGIC)
102
+ self._mm.write(struct.pack('>I', VERSION))
103
+ self._mm.write(b'\x00' * (HEADER_SIZE - 8))
104
+ self._mm.flush()
105
+
106
+ self._sequence = 0
107
+
108
+ def write_snapshot(self, window) -> None:
109
+ """全量写入 ClaudeWindow 快照"""
110
+ try:
111
+ blocks = []
112
+ for b in window.blocks:
113
+ d = _component_to_dict(b)
114
+ d['block_id'] = _block_id_from_dict(d)
115
+ blocks.append(d)
116
+ # agent_panel 序列化(状态型组件,独立于 blocks)
117
+ agent_panel_dict = None
118
+ if window.agent_panel:
119
+ agent_panel_dict = _component_to_dict(window.agent_panel)
120
+ agent_panel_dict['block_id'] = _block_id_from_dict(agent_panel_dict)
121
+
122
+ # option_block 序列化(状态型组件,独立于 blocks)
123
+ option_block_dict = None
124
+ if window.option_block:
125
+ option_block_dict = _component_to_dict(window.option_block)
126
+ option_block_dict['block_id'] = _block_id_from_dict(option_block_dict)
127
+
128
+ snapshot = {
129
+ "blocks": blocks,
130
+ "status_line": _component_to_dict(window.status_line) if window.status_line else None,
131
+ "bottom_bar": _component_to_dict(window.bottom_bar) if window.bottom_bar else None,
132
+ "agent_panel": agent_panel_dict,
133
+ "option_block": option_block_dict,
134
+ "input_area_text": window.input_area_text,
135
+ "timestamp": window.timestamp,
136
+ "layout_mode": window.layout_mode,
137
+ }
138
+ data = json.dumps(snapshot, ensure_ascii=False).encode('utf-8')
139
+
140
+ # 超出可用空间则跳过(理论上 200MB 足够)
141
+ if COMPLETED_OFFSET + len(data) > MMAP_SIZE:
142
+ return
143
+
144
+ self._sequence += 1
145
+ mm = self._mm
146
+ mm.seek(COMPLETED_OFFSET)
147
+ mm.write(data)
148
+
149
+ # 更新 header(snapshot_len + sequence)
150
+ mm.seek(HEADER_OFFSET + _H_SNAPSHOT_LEN)
151
+ mm.write(struct.pack('>II', len(data), self._sequence))
152
+ mm.flush()
153
+ except Exception:
154
+ pass
155
+
156
+ def close(self):
157
+ try:
158
+ self._mm.close()
159
+ self._f.close()
160
+ self._path.unlink(missing_ok=True)
161
+ except Exception:
162
+ pass
163
+
164
+
165
+ # ── Reader ────────────────────────────────────────────────────────────────────
166
+
167
+ class SharedStateReader:
168
+ """读端:其他进程持有,按需调用 read() 获取最新快照。
169
+
170
+ 使用直接文件 I/O 而非 mmap,避免 macOS 上 mmap ACCESS_READ 不可靠地
171
+ 反映跨进程写入更新的问题。每次 read() 打开文件读取后关闭,保证读到最新数据。
172
+ """
173
+
174
+ _EMPTY = {"blocks": [], "status_line": None, "bottom_bar": None, "option_block": None}
175
+
176
+ def __init__(self, session_name: str):
177
+ self._path = get_mq_path(session_name)
178
+
179
+ def read(self) -> dict:
180
+ """读取当前完整快照,返回 dict"""
181
+ try:
182
+ with open(self._path, 'rb') as f:
183
+ header = f.read(16)
184
+ if len(header) < 16 or header[:4] != MAGIC:
185
+ return self._EMPTY
186
+ version = struct.unpack('>I', header[4:8])[0]
187
+ if version < 2:
188
+ return self._EMPTY
189
+ snapshot_len, _sequence = struct.unpack('>II', header[8:16])
190
+ if snapshot_len == 0:
191
+ return self._EMPTY
192
+ f.seek(COMPLETED_OFFSET)
193
+ return json.loads(f.read(snapshot_len).decode('utf-8'))
194
+ except Exception:
195
+ return self._EMPTY
196
+
197
+ def close(self):
198
+ pass # 无持久资源需要释放
@@ -0,0 +1,38 @@
1
+ """
2
+ Remote Claude 使用统计模块
3
+
4
+ 全局接口:
5
+ track(category, event, **kwargs) —— 记录事件
6
+ close() —— 关闭前刷新
7
+ """
8
+
9
+ _ENABLED = True
10
+ _MIXPANEL_TOKEN = 'c4d804fc1fe4337132e4da90fdb690c9'
11
+
12
+ from .collector import StatsCollector
13
+
14
+ _collector = StatsCollector(enabled=_ENABLED)
15
+ # 模块加载时自动初始化 Mixpanel(无需外部配置)
16
+ _collector.set_mixpanel_token(_MIXPANEL_TOKEN)
17
+ _collector.report_install()
18
+
19
+
20
+ def track(category: str, event: str, **kwargs) -> None:
21
+ """记录事件(非阻塞,线程安全,异常不传播)"""
22
+ _collector.track(category, event, **kwargs)
23
+
24
+
25
+ def init_mixpanel(token: str) -> None:
26
+ """配置 Mixpanel token(保留接口兼容性,通常无需手动调用)"""
27
+ _collector.set_mixpanel_token(token)
28
+ _collector.report_install()
29
+
30
+
31
+ def report_daily(date: str = None) -> None:
32
+ """手动触发聚合上报(CLI --report 命令使用)"""
33
+ _collector.report_daily(date)
34
+
35
+
36
+ def close() -> None:
37
+ """关闭前刷新队列"""
38
+ _collector.close()
@@ -0,0 +1,325 @@
1
+ """
2
+ StatsCollector:事件收集器
3
+
4
+ - 内存队列(deque)接收 track() 调用(O(1),无 I/O)
5
+ - 每 10 秒或满 50 条时批量写入 SQLite(WAL 模式)
6
+ - 每日聚合上报 Mixpanel(~22 条/天)
7
+ - 所有异常均被捕获,绝不影响主流程
8
+ """
9
+
10
+ import os
11
+ import sqlite3
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from .machine import get_machine_id, get_machine_info
19
+
20
+ # SQLite 数据库路径(持久化,不受 /tmp 清理影响)
21
+ _DB_DIR = Path.home() / ".local" / "share" / "remote-claude"
22
+ _DB_PATH = _DB_DIR / "stats.db"
23
+
24
+ # 批量写入阈值
25
+ _FLUSH_INTERVAL = 10.0 # 秒
26
+ _FLUSH_BATCH = 50 # 条
27
+
28
+ # 数据保留天数
29
+ _EVENTS_RETENTION = 90 # 天
30
+ _SUMMARY_RETENTION = 365 # 天
31
+
32
+
33
+ class StatsCollector:
34
+ """事件收集器:内存队列 + SQLite 批量写入 + 定时 Mixpanel 聚合上报"""
35
+
36
+ def __init__(self, enabled: bool = True):
37
+ self._enabled = enabled
38
+ self._queue: deque = deque(maxlen=10000)
39
+ self._lock = threading.Lock()
40
+ self._machine_id = get_machine_id()
41
+ self._conn: Optional[sqlite3.Connection] = None
42
+ self._mp = None # Mixpanel 实例,延迟初始化
43
+ self._mp_token: str = ""
44
+ self._last_flush = 0.0
45
+ self._last_report_date: str = ""
46
+ self._is_first_run = False # 是否首次运行(需上报 install 事件)
47
+
48
+ if self._enabled:
49
+ self._init_db()
50
+ self._check_first_run()
51
+ # 后台线程定时 flush
52
+ t = threading.Thread(target=self._flush_loop, daemon=True)
53
+ t.start()
54
+
55
+ # ── 公开接口 ──────────────────────────────────────────────────────────────
56
+
57
+ def track(self, category: str, event: str, session_name: str = '',
58
+ chat_id: str = '', value: int = 1, detail: str = '') -> None:
59
+ """记录事件到本地(非阻塞,线程安全)"""
60
+ if not self._enabled:
61
+ return
62
+ try:
63
+ now = time.time()
64
+ date = time.strftime('%Y-%m-%d', time.localtime(now))
65
+ # chat_id 脱敏:只保留前 8 位
66
+ safe_chat_id = chat_id[:8] if chat_id else ''
67
+ row = (now, date, category, event, session_name,
68
+ safe_chat_id, value, detail, self._machine_id)
69
+ with self._lock:
70
+ self._queue.append(row)
71
+ should_flush = len(self._queue) >= _FLUSH_BATCH
72
+ if should_flush:
73
+ threading.Thread(target=self._flush, daemon=True).start()
74
+ except Exception:
75
+ pass
76
+
77
+ def set_mixpanel_token(self, token: str) -> None:
78
+ """配置 Mixpanel token(来自 .env)"""
79
+ if not token:
80
+ return
81
+ self._mp_token = token
82
+ try:
83
+ import mixpanel
84
+ self._mp = mixpanel.Mixpanel(token)
85
+ except ImportError:
86
+ pass # mixpanel 未安装时静默跳过
87
+
88
+ def check_and_report(self) -> None:
89
+ """检查是否需要上报昨日数据(跨天检测,可在后台调用)"""
90
+ if not self._enabled or not self._mp:
91
+ return
92
+ try:
93
+ today = time.strftime('%Y-%m-%d')
94
+ if self._last_report_date == today:
95
+ return
96
+ yesterday = time.strftime('%Y-%m-%d',
97
+ time.localtime(time.time() - 86400))
98
+ self.report_daily(yesterday)
99
+ self._last_report_date = today
100
+ except Exception:
101
+ pass
102
+
103
+ def report_daily(self, date: Optional[str] = None) -> None:
104
+ """聚合指定日期数据并上报 Mixpanel(默认昨天)"""
105
+ if not self._enabled or not self._mp:
106
+ return
107
+ try:
108
+ if date is None:
109
+ date = time.strftime('%Y-%m-%d',
110
+ time.localtime(time.time() - 86400))
111
+ self._flush() # 先把队列里的数据落库
112
+
113
+ conn = self._get_conn()
114
+ rows = conn.execute(
115
+ "SELECT category, event, COUNT(*), SUM(value) "
116
+ "FROM events WHERE date=? GROUP BY category, event",
117
+ (date,)
118
+ ).fetchall()
119
+
120
+ if not rows:
121
+ return
122
+
123
+ machine_info = get_machine_info()
124
+ for category, event, count, total_value in rows:
125
+ self._mp_track('daily_summary', {
126
+ 'category': category,
127
+ 'event': event,
128
+ 'count': count,
129
+ 'total_value': total_value,
130
+ 'date': date,
131
+ **machine_info,
132
+ })
133
+
134
+ # 上报 heartbeat
135
+ active_sessions = self._count_active_sessions(date, conn)
136
+ self._mp_track('heartbeat', {
137
+ 'date': date,
138
+ 'active_sessions': active_sessions,
139
+ **machine_info,
140
+ })
141
+
142
+ # 写入 daily_summary 表(本地留存)
143
+ for category, event, count, total_value in rows:
144
+ conn.execute(
145
+ "INSERT OR REPLACE INTO daily_summary "
146
+ "(date, category, event, count, total_value) "
147
+ "VALUES (?, ?, ?, ?, ?)",
148
+ (date, category, event, count, total_value)
149
+ )
150
+ conn.commit()
151
+
152
+ except Exception:
153
+ pass
154
+
155
+ def close(self) -> None:
156
+ """关闭前刷新队列"""
157
+ if self._enabled:
158
+ try:
159
+ self._flush()
160
+ except Exception:
161
+ pass
162
+
163
+ # ── 内部方法 ──────────────────────────────────────────────────────────────
164
+
165
+ def _init_db(self) -> None:
166
+ """初始化 SQLite 数据库"""
167
+ try:
168
+ _DB_DIR.mkdir(parents=True, exist_ok=True)
169
+ conn = self._get_conn()
170
+ conn.executescript("""
171
+ PRAGMA journal_mode=WAL;
172
+ PRAGMA synchronous=NORMAL;
173
+
174
+ CREATE TABLE IF NOT EXISTS events (
175
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
176
+ timestamp REAL NOT NULL,
177
+ date TEXT NOT NULL,
178
+ category TEXT NOT NULL,
179
+ event TEXT NOT NULL,
180
+ session_name TEXT DEFAULT '',
181
+ chat_id TEXT DEFAULT '',
182
+ value INTEGER DEFAULT 1,
183
+ detail TEXT DEFAULT '',
184
+ machine_id TEXT NOT NULL
185
+ );
186
+
187
+ CREATE TABLE IF NOT EXISTS daily_summary (
188
+ date TEXT,
189
+ category TEXT,
190
+ event TEXT,
191
+ count INTEGER,
192
+ total_value INTEGER,
193
+ PRIMARY KEY (date, category, event)
194
+ );
195
+
196
+ CREATE TABLE IF NOT EXISTS meta (
197
+ key TEXT PRIMARY KEY,
198
+ value TEXT
199
+ );
200
+
201
+ CREATE INDEX IF NOT EXISTS idx_events_date
202
+ ON events(date);
203
+ CREATE INDEX IF NOT EXISTS idx_events_category
204
+ ON events(category, event);
205
+ """)
206
+ conn.commit()
207
+ # 清理过期数据
208
+ self._cleanup_old_data(conn)
209
+ except Exception:
210
+ pass
211
+
212
+ def _check_first_run(self) -> None:
213
+ """检查是否首次运行"""
214
+ try:
215
+ conn = self._get_conn()
216
+ row = conn.execute(
217
+ "SELECT value FROM meta WHERE key='first_run'"
218
+ ).fetchone()
219
+ if row is None:
220
+ self._is_first_run = True
221
+ conn.execute(
222
+ "INSERT INTO meta (key, value) VALUES ('first_run', ?)",
223
+ (time.strftime('%Y-%m-%d'),)
224
+ )
225
+ conn.commit()
226
+ except Exception:
227
+ pass
228
+
229
+ def _get_conn(self) -> sqlite3.Connection:
230
+ """获取 SQLite 连接(线程本地)"""
231
+ if self._conn is None:
232
+ self._conn = sqlite3.connect(str(_DB_PATH), check_same_thread=False)
233
+ return self._conn
234
+
235
+ def _flush(self) -> None:
236
+ """批量写入 SQLite"""
237
+ with self._lock:
238
+ if not self._queue:
239
+ return
240
+ rows = list(self._queue)
241
+ self._queue.clear()
242
+
243
+ try:
244
+ conn = self._get_conn()
245
+ conn.executemany(
246
+ "INSERT INTO events "
247
+ "(timestamp, date, category, event, session_name, "
248
+ "chat_id, value, detail, machine_id) "
249
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
250
+ rows
251
+ )
252
+ conn.commit()
253
+ self._last_flush = time.time()
254
+ except Exception:
255
+ # 写失败,把数据放回队列头部(尽力保留)
256
+ with self._lock:
257
+ for row in reversed(rows):
258
+ self._queue.appendleft(row)
259
+
260
+ def _flush_loop(self) -> None:
261
+ """后台定时 flush 线程"""
262
+ while True:
263
+ try:
264
+ time.sleep(10)
265
+ elapsed = time.time() - self._last_flush
266
+ with self._lock:
267
+ has_data = bool(self._queue)
268
+ if has_data and elapsed >= _FLUSH_INTERVAL:
269
+ self._flush()
270
+ self.check_and_report()
271
+ except Exception:
272
+ pass
273
+
274
+ def _cleanup_old_data(self, conn: sqlite3.Connection) -> None:
275
+ """清理过期数据"""
276
+ try:
277
+ conn.execute(
278
+ "DELETE FROM events WHERE date < date('now', ?)",
279
+ (f"-{_EVENTS_RETENTION} days",)
280
+ )
281
+ conn.execute(
282
+ "DELETE FROM daily_summary WHERE date < date('now', ?)",
283
+ (f"-{_SUMMARY_RETENTION} days",)
284
+ )
285
+ conn.commit()
286
+ except Exception:
287
+ pass
288
+
289
+ def _mp_track(self, event_name: str, properties: dict) -> None:
290
+ """上报单条 Mixpanel 事件"""
291
+ if not self._mp:
292
+ return
293
+ try:
294
+ self._mp.track(self._machine_id, event_name, properties)
295
+ except Exception:
296
+ pass
297
+
298
+ def _count_active_sessions(self, date: str,
299
+ conn: sqlite3.Connection) -> int:
300
+ """统计指定日期有 start 事件的会话数"""
301
+ try:
302
+ row = conn.execute(
303
+ "SELECT COUNT(DISTINCT session_name) FROM events "
304
+ "WHERE date=? AND category='session' AND event='start'",
305
+ (date,)
306
+ ).fetchone()
307
+ return row[0] if row else 0
308
+ except Exception:
309
+ return 0
310
+
311
+ def report_install(self) -> None:
312
+ """首次运行时上报 install 事件和 user profile"""
313
+ if not self._mp or not self._is_first_run:
314
+ return
315
+ try:
316
+ machine_info = get_machine_info()
317
+ import datetime
318
+ self._mp.people_set(self._machine_id, {
319
+ '$name': machine_info['hostname'],
320
+ **machine_info,
321
+ 'first_seen': datetime.datetime.now().isoformat(),
322
+ })
323
+ self._mp_track('install', machine_info)
324
+ except Exception:
325
+ pass
@@ -0,0 +1,47 @@
1
+ """
2
+ 机器标识模块
3
+
4
+ 持久化 UUID(~/.remote-claude-id),用于 Mixpanel distinct_id 和跨机器去重。
5
+ """
6
+
7
+ import os
8
+ import platform
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+
13
+ _ID_FILE = Path.home() / ".remote-claude-id"
14
+ _machine_id: str | None = None
15
+
16
+
17
+ def get_machine_id() -> str:
18
+ """获取(或生成)机器 UUID,持久化到 ~/.remote-claude-id"""
19
+ global _machine_id
20
+ if _machine_id:
21
+ return _machine_id
22
+
23
+ if _ID_FILE.exists():
24
+ try:
25
+ _machine_id = _ID_FILE.read_text().strip()
26
+ if _machine_id:
27
+ return _machine_id
28
+ except Exception:
29
+ pass
30
+
31
+ # 首次生成
32
+ _machine_id = str(uuid.uuid4())
33
+ try:
34
+ _ID_FILE.write_text(_machine_id)
35
+ except Exception:
36
+ pass # 写失败也继续,只是无法持久化
37
+
38
+ return _machine_id
39
+
40
+
41
+ def get_machine_info() -> dict:
42
+ """获取机器基础信息(用于 Mixpanel user profile)"""
43
+ return {
44
+ "hostname": platform.node(),
45
+ "os": f"{platform.system()} {platform.release()}",
46
+ "python": platform.python_version(),
47
+ }