remote-claude 1.0.2 → 1.0.4

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.
@@ -40,6 +40,7 @@ from utils.session import ensure_user_data_dir, USER_DATA_DIR
40
40
  # ── 常量 ──────────────────────────────────────────────────────────────────────
41
41
  INITIAL_WINDOW = 30 # 首次 attach 最多显示最近 30 个 blocks
42
42
  from .config import MAX_CARD_BLOCKS # 单张卡片最多 N 个 blocks → 超限冻结(可通过 .env 配置)
43
+ CARD_SIZE_LIMIT = 25 * 1024 # 25KB,飞书限制 30KB,留 5KB 余量
43
44
  POLL_INTERVAL = 1.0 # 轮询间隔(秒)
44
45
  RAPID_INTERVAL = 0.2 # 快速轮询间隔(秒)
45
46
  RAPID_DURATION = 2.0 # 快速轮询持续时间(秒)
@@ -153,6 +154,16 @@ class SharedMemoryPoller:
153
154
  if exc:
154
155
  logger.error(f"轮询 Task 异常: chat_id={chat_id[:8]}..., {exc}", exc_info=exc)
155
156
 
157
+ def read_snapshot(self, chat_id: str) -> Optional[dict]:
158
+ """直接读取指定 chat_id 的当前共享内存快照(供 handle_option_select 等即时查询使用)"""
159
+ tracker = self._trackers.get(chat_id)
160
+ if tracker and tracker.reader:
161
+ try:
162
+ return tracker.reader.read()
163
+ except Exception as e:
164
+ logger.warning(f"read_snapshot 失败: {e}")
165
+ return None
166
+
156
167
  def kick(self, chat_id: str) -> None:
157
168
  """触发立即轮询并进入快速轮询模式"""
158
169
  self._rapid_until[chat_id] = time.time() + RAPID_DURATION
@@ -191,8 +202,8 @@ class SharedMemoryPoller:
191
202
  logger.error(f"_poll_once 异常: {e}", exc_info=True)
192
203
 
193
204
  async def _poll_once(self, tracker: StreamTracker) -> None:
194
- """单次轮询:读取共享内存 → diff → 创建/更新卡片"""
195
- # 延迟初始化 Reader
205
+ """单次轮询:读取共享内存 → diff → 创建/更新卡片 → 就绪通知"""
206
+ # 步骤 1:延迟初始化 Reader
196
207
  if tracker.reader is None:
197
208
  try:
198
209
  from shared_state import get_mq_path, SharedStateReader
@@ -219,84 +230,114 @@ class SharedMemoryPoller:
219
230
  agent_panel = state.get("agent_panel")
220
231
  option_block = state.get("option_block")
221
232
  cli_type = state.get("cli_type", "claude")
233
+ # timestamp 存在说明 server 已写入有效快照(即使内容全空,如 Codex 就绪等待输入)
234
+ has_valid_snapshot = state.get("timestamp") is not None
222
235
 
223
- # 就绪状态转换检测
224
- await self._check_ready_notification(tracker, blocks, status_line, option_block, cli_type)
236
+ # 步骤 2:仅计算就绪状态,不发送通知
237
+ should_notify = self._update_ready_state(tracker, blocks, status_line, option_block, agent_panel)
225
238
 
239
+ # 步骤 3:卡片操作(含创建/更新/拆分)
240
+ await self._do_card_update(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type, has_valid_snapshot=has_valid_snapshot)
241
+
242
+ # 步骤 4:通知在卡片操作之后发送,确保新卡先出现
243
+ if should_notify:
244
+ await self._send_ready_notification(tracker, cli_type)
245
+
246
+ async def _do_card_update(
247
+ self, tracker: StreamTracker, blocks: List[dict],
248
+ status_line: Optional[dict], bottom_bar: Optional[dict],
249
+ agent_panel: Optional[dict], option_block: Optional[dict],
250
+ cli_type: str,
251
+ has_valid_snapshot: bool = False,
252
+ ) -> None:
253
+ """卡片操作主体:获取活跃卡片 → 创建/更新/拆分"""
226
254
  # 获取活跃卡片(最后一张且未冻结)
227
255
  active = None
228
256
  if tracker.cards and not tracker.cards[-1].frozen:
229
257
  active = tracker.cards[-1]
230
258
 
231
259
  if not blocks and not status_line and not bottom_bar and not agent_panel and not option_block and active is None:
232
- return # 完全无内容且无活跃卡片时不创建卡片
260
+ if not has_valid_snapshot:
261
+ return # 真的还没有快照,不创建卡片
262
+ # 有效快照但内容全空(如 Codex 就绪等待输入),继续创建就绪卡片
233
263
 
234
264
  if active is None:
235
265
  # 需要创建新卡片
236
266
  await self._create_new_card(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
237
- else:
238
- # 有活跃卡片,检查是否需要更新
239
- blocks_slice = blocks[active.start_idx:]
267
+ return
240
268
 
241
- # blocks 骤降检测(compact/重启导致 blocks 从头累积)
242
- if len(blocks) < active.start_idx:
243
- logger.warning(
244
- f"[blocks regression] len(blocks)={len(blocks)} < start_idx={active.start_idx}, "
245
- f"resetting start_idx to 0 (session={tracker.session_name})"
246
- )
247
- active.start_idx = 0
248
- blocks_slice = blocks[0:]
249
- tracker.content_hash = "" # 强制刷新
269
+ # 有活跃卡片,检查是否需要更新
270
+ blocks_slice = blocks[active.start_idx:]
250
271
 
251
- # 超限检查
252
- if len(blocks_slice) > MAX_CARD_BLOCKS:
253
- await self._freeze_and_split(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
254
- return
272
+ # blocks 骤降检测(compact/重启导致 blocks 从头累积)
273
+ if len(blocks) < active.start_idx:
274
+ logger.warning(
275
+ f"[blocks regression] len(blocks)={len(blocks)} < start_idx={active.start_idx}, "
276
+ f"resetting start_idx to 0 (session={tracker.session_name})"
277
+ )
278
+ active.start_idx = 0
279
+ blocks_slice = blocks[0:]
280
+ tracker.content_hash = "" # 强制刷新
255
281
 
256
- # hash diff
257
- new_hash = self._compute_hash(blocks_slice, status_line, bottom_bar, agent_panel, option_block)
258
- if new_hash == tracker.content_hash:
259
- return # 无变化
282
+ # 超限检查
283
+ if len(blocks_slice) > MAX_CARD_BLOCKS:
284
+ await self._freeze_and_split(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
285
+ return
260
286
 
261
- # 更新卡片
262
- from .card_builder import build_stream_card
263
- card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
287
+ # hash diff
288
+ new_hash = self._compute_hash(blocks_slice, status_line, bottom_bar, agent_panel, option_block)
289
+ if new_hash == tracker.content_hash:
290
+ return # 无变化
291
+
292
+ # 更新卡片
293
+ from .card_builder import build_stream_card
294
+ card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
264
295
 
265
- active.sequence += 1
266
- success = await self._card_service.update_card(
267
- card_id=active.card_id,
268
- sequence=active.sequence,
269
- card_content=card_dict,
296
+ # 大小超限检查(与 blocks 数量超限同一套逻辑)
297
+ card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
298
+ if card_size > CARD_SIZE_LIMIT:
299
+ freeze_count = self._find_freeze_count(blocks_slice, tracker.session_name)
300
+ await self._freeze_and_split(
301
+ tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
302
+ cli_type=cli_type, freeze_count=freeze_count,
270
303
  )
304
+ return
271
305
 
272
- if getattr(success, 'is_element_limit', False):
273
- # 元素超限:冻结旧卡 + 推新流式卡
274
- await self._handle_element_limit(
275
- tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
276
- cli_type=cli_type,
277
- )
278
- return
279
- elif not success:
280
- # 降级:创建新卡片替代
281
- logger.warning(
282
- f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
283
- )
284
- _track_stats('card', 'fallback', session_name=tracker.session_name,
285
- chat_id=tracker.chat_id)
286
- new_card_id = await self._card_service.create_card(card_dict)
287
- if new_card_id:
288
- await self._card_service.send_card(tracker.chat_id, new_card_id)
289
- active.card_id = new_card_id
290
- active.sequence = 0
291
- else:
292
- _track_stats('card', 'update', session_name=tracker.session_name,
293
- chat_id=tracker.chat_id)
306
+ active.sequence += 1
307
+ success = await self._card_service.update_card(
308
+ card_id=active.card_id,
309
+ sequence=active.sequence,
310
+ card_content=card_dict,
311
+ )
294
312
 
295
- tracker.content_hash = new_hash
296
- logger.debug(
297
- f"[UPDATE] session={tracker.session_name} blocks={len(blocks_slice)} "
298
- f"seq={active.sequence} hash={new_hash[:8]}"
313
+ if getattr(success, 'is_element_limit', False):
314
+ # 元素超限:冻结旧卡 + 推新流式卡
315
+ await self._handle_element_limit(
316
+ tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
317
+ cli_type=cli_type,
318
+ )
319
+ return
320
+ elif not success:
321
+ # 降级:创建新卡片替代
322
+ logger.warning(
323
+ f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
299
324
  )
325
+ _track_stats('card', 'fallback', session_name=tracker.session_name,
326
+ chat_id=tracker.chat_id)
327
+ new_card_id = await self._card_service.create_card(card_dict)
328
+ if new_card_id:
329
+ await self._card_service.send_card(tracker.chat_id, new_card_id)
330
+ active.card_id = new_card_id
331
+ active.sequence = 0
332
+ else:
333
+ _track_stats('card', 'update', session_name=tracker.session_name,
334
+ chat_id=tracker.chat_id)
335
+
336
+ tracker.content_hash = new_hash
337
+ logger.debug(
338
+ f"[UPDATE] session={tracker.session_name} blocks={len(blocks_slice)} "
339
+ f"seq={active.sequence} hash={new_hash[:8]}"
340
+ )
300
341
 
301
342
  async def _create_new_card(
302
343
  self, tracker: StreamTracker, blocks: List[dict],
@@ -321,11 +362,20 @@ class SharedMemoryPoller:
321
362
  )
322
363
 
323
364
  blocks_slice = blocks[start_idx:]
324
- if not blocks_slice and not status_line and not bottom_bar and not agent_panel and not option_block:
325
- return
365
+ # 注意:不在此处提前 return,上层 _do_card_update 已做过滤,
366
+ # 走到这里说明确实需要创建卡片(如 Codex 就绪等待输入的空内容卡片)
326
367
 
327
368
  from .card_builder import build_stream_card
328
369
  card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
370
+
371
+ # 新卡大小检查:超限则从头部裁剪
372
+ card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
373
+ while card_size > CARD_SIZE_LIMIT and len(blocks_slice) > 1:
374
+ blocks_slice = blocks_slice[1:]
375
+ start_idx += 1
376
+ card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
377
+ card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
378
+
329
379
  card_id = await self._card_service.create_card(card_dict)
330
380
 
331
381
  if card_id:
@@ -384,6 +434,24 @@ class SharedMemoryPoller:
384
434
  f"[ELEMENT_LIMIT_SPLIT] session={tracker.session_name} "
385
435
  f"new_start={new_start} blocks={len(new_blocks)} card_id={new_card_id}"
386
436
  )
437
+ tracker.last_notify_message_id = None
438
+
439
+ def _find_freeze_count(self, blocks_slice: List[dict], session_name: str) -> int:
440
+ """二分查找冻结卡片能容纳的最大 blocks 数(保证卡片 JSON 大小 ≤ CARD_SIZE_LIMIT)"""
441
+ from .card_builder import build_stream_card
442
+ lo, hi = 1, len(blocks_slice)
443
+ result = 1
444
+ while lo <= hi:
445
+ mid = (lo + hi) // 2
446
+ card = build_stream_card(blocks_slice[:mid], None, None,
447
+ is_frozen=True, session_name=session_name)
448
+ size = len(json.dumps(card, ensure_ascii=False).encode('utf-8'))
449
+ if size <= CARD_SIZE_LIMIT:
450
+ result = mid
451
+ lo = mid + 1
452
+ else:
453
+ hi = mid - 1
454
+ return result
387
455
 
388
456
  async def _freeze_and_split(
389
457
  self, tracker: StreamTracker, blocks: List[dict],
@@ -391,12 +459,15 @@ class SharedMemoryPoller:
391
459
  agent_panel: Optional[dict] = None,
392
460
  option_block: Optional[dict] = None,
393
461
  cli_type: str = "claude",
462
+ freeze_count: Optional[int] = None,
394
463
  ) -> None:
395
464
  """冻结当前卡片 + 开新卡"""
396
465
  active = tracker.cards[-1]
466
+ count = freeze_count if freeze_count is not None else MAX_CARD_BLOCKS
467
+ reason = 'size' if freeze_count is not None else 'count'
397
468
 
398
- # 冻结当前卡片(只保留前 MAX_CARD_BLOCKS 个 blocks,移除状态区和按钮)
399
- frozen_blocks = blocks[active.start_idx:active.start_idx + MAX_CARD_BLOCKS]
469
+ # 冻结当前卡片(只保留前 count 个 blocks,移除状态区和按钮)
470
+ frozen_blocks = blocks[active.start_idx:active.start_idx + count]
400
471
  from .card_builder import build_stream_card
401
472
  frozen_card = build_stream_card(frozen_blocks, None, None, is_frozen=True)
402
473
  active.sequence += 1
@@ -406,16 +477,25 @@ class SharedMemoryPoller:
406
477
  chat_id=tracker.chat_id)
407
478
  logger.info(
408
479
  f"[FREEZE] session={tracker.session_name} card_id={active.card_id} "
409
- f"blocks=[{active.start_idx}:{active.start_idx + MAX_CARD_BLOCKS}]"
480
+ f"blocks=[{active.start_idx}:{active.start_idx + count}] reason={reason}"
410
481
  )
411
482
 
412
483
  # 创建新卡片
413
- new_start = active.start_idx + MAX_CARD_BLOCKS
484
+ new_start = active.start_idx + count
414
485
  new_blocks = blocks[new_start:]
415
486
  if not new_blocks:
416
487
  return
417
488
 
418
489
  new_card_dict = build_stream_card(new_blocks, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
490
+
491
+ # 新卡大小检查:超限则从头部裁剪
492
+ new_card_size = len(json.dumps(new_card_dict, ensure_ascii=False).encode('utf-8'))
493
+ while new_card_size > CARD_SIZE_LIMIT and len(new_blocks) > 1:
494
+ new_blocks = new_blocks[1:]
495
+ new_start += 1
496
+ new_card_dict = build_stream_card(new_blocks, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
497
+ new_card_size = len(json.dumps(new_card_dict, ensure_ascii=False).encode('utf-8'))
498
+
419
499
  new_card_id = await self._card_service.create_card(new_card_dict)
420
500
  if new_card_id:
421
501
  await self._card_service.send_card(tracker.chat_id, new_card_id)
@@ -425,54 +505,59 @@ class SharedMemoryPoller:
425
505
  f"[NEW after FREEZE] session={tracker.session_name} start_idx={new_start} "
426
506
  f"blocks={len(new_blocks)} card_id={new_card_id}"
427
507
  )
508
+ tracker.last_notify_message_id = None
428
509
 
429
- async def _check_ready_notification(
510
+ def _update_ready_state(
430
511
  self, tracker: StreamTracker,
431
512
  blocks: list, status_line: Optional[dict], option_block: Optional[dict],
432
- cli_type: str = "claude"
433
- ) -> None:
434
- """检测就绪状态转换(忙碌→就绪),群聊中发送 @所有人 提醒"""
435
- current_ready = _is_ready(blocks, status_line, option_block)
513
+ agent_panel: Optional[dict] = None,
514
+ ) -> bool:
515
+ """更新就绪状态,返回是否需要发送就绪通知(不执行发送)"""
516
+ current_ready = _is_ready(blocks, status_line, option_block, agent_panel)
436
517
  prev_ready = tracker.prev_is_ready
437
518
  tracker.prev_is_ready = current_ready
519
+ return current_ready and not prev_ready and tracker.is_group and _notify_enabled
438
520
 
439
- if current_ready and not prev_ready and tracker.is_group and _notify_enabled:
440
- count = _increment_ready_count()
441
- uid = tracker.notify_user_id or "all"
442
- cli_name = "Claude" if cli_type == "claude" else "Codex"
443
- logger.info(f"就绪提醒: chat_id={tracker.chat_id[:8]}..., count={count}, uid={uid}, "
444
- f"last_msg={'有' if tracker.last_notify_message_id else '无'}")
445
-
446
- if tracker.last_notify_message_id and uid != "all" and _urgent_enabled:
447
- # 已有通知消息 + 加急开关开启 → 尝试加急
448
- try:
449
- ok = await self._card_service.send_urgent_app(
450
- tracker.last_notify_message_id, [uid]
451
- )
452
- if ok:
453
- # 加急成功 → 15 秒后自动取消
454
- asyncio.create_task(self._cancel_urgent_later(
455
- tracker.last_notify_message_id, [uid], delay=5
456
- ))
457
- else:
458
- # 加急失败(权限未开通等)→ 降级发新消息
459
- label = ""
460
- text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
461
- msg_id = await self._card_service.send_text(tracker.chat_id, text)
462
- if msg_id:
463
- tracker.last_notify_message_id = msg_id
464
- except Exception as e:
465
- logger.warning(f"加急通知失败: {e}")
466
- else:
467
- # 首次通知(或无法加急时)→ 发新消息,记录 message_id
468
- label = "所有人" if uid == "all" else ""
469
- text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
470
- try:
521
+ async def _send_ready_notification(
522
+ self, tracker: StreamTracker, cli_type: str = "claude"
523
+ ) -> None:
524
+ """发送就绪通知(加急或新消息),应在卡片操作完成后调用"""
525
+ count = _increment_ready_count()
526
+ uid = tracker.notify_user_id or "all"
527
+ cli_name = "Claude" if cli_type == "claude" else "Codex"
528
+ logger.info(f"就绪提醒: chat_id={tracker.chat_id[:8]}..., count={count}, uid={uid}, "
529
+ f"last_msg={'有' if tracker.last_notify_message_id else '无'}")
530
+
531
+ if tracker.last_notify_message_id and uid != "all" and _urgent_enabled:
532
+ # 已有通知消息 + 加急开关开启 → 尝试加急
533
+ try:
534
+ ok = await self._card_service.send_urgent_app(
535
+ tracker.last_notify_message_id, [uid]
536
+ )
537
+ if ok:
538
+ # 加急成功 → 5 秒后自动取消
539
+ asyncio.create_task(self._cancel_urgent_later(
540
+ tracker.last_notify_message_id, [uid], delay=5
541
+ ))
542
+ else:
543
+ # 加急失败(权限未开通等)→ 降级发新消息
544
+ label = ""
545
+ text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
471
546
  msg_id = await self._card_service.send_text(tracker.chat_id, text)
472
547
  if msg_id:
473
548
  tracker.last_notify_message_id = msg_id
474
- except Exception as e:
475
- logger.warning(f"就绪提醒发送失败: {e}")
549
+ except Exception as e:
550
+ logger.warning(f"加急通知失败: {e}")
551
+ else:
552
+ # 首次通知(或无法加急时)→ 发新消息,记录 message_id
553
+ label = "所有人" if uid == "all" else ""
554
+ text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
555
+ try:
556
+ msg_id = await self._card_service.send_text(tracker.chat_id, text)
557
+ if msg_id:
558
+ tracker.last_notify_message_id = msg_id
559
+ except Exception as e:
560
+ logger.warning(f"就绪提醒发送失败: {e}")
476
561
 
477
562
  async def _cancel_urgent_later(self, message_id: str, user_ids: list, delay: float = 15) -> None:
478
563
  """延迟取消加急通知"""
@@ -536,10 +621,11 @@ class SharedMemoryPoller:
536
621
 
537
622
  # ── 模块级辅助函数 ────────────────────────────────────────────────────────────
538
623
 
539
- def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[dict]) -> bool:
540
- """数据层就绪判断:无 streaming block、无 status_line(option_block 不影响就绪)"""
624
+ def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[dict], agent_panel: Optional[dict] = None) -> bool:
625
+ """数据层就绪判断:无 streaming block、无 status_line、无后台 agent(option_block 不影响就绪)"""
541
626
  has_streaming = any(b.get("is_streaming", False) for b in blocks)
542
- return not has_streaming and status_line is None
627
+ has_agents = agent_panel is not None
628
+ return not has_streaming and status_line is None and not has_agents
543
629
 
544
630
 
545
631
  _READY_COUNT_FILE = USER_DATA_DIR / "ready_notify_count"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
package/pyproject.toml CHANGED
@@ -8,6 +8,7 @@ dependencies = [
8
8
  "python-dotenv>=1.0.0",
9
9
  "pyte>=0.8.0",
10
10
  "mixpanel>=5.0.0",
11
+ "python-socks[asyncio]>=2.0.0",
11
12
  ]
12
13
 
13
14
  [project.scripts]