remote-claude 0.2.13 → 0.2.15
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/README.md +6 -4
- package/init.sh +5 -0
- package/lark_client/card_builder.py +51 -1
- package/lark_client/card_service.py +66 -4
- package/lark_client/lark_handler.py +25 -7
- package/lark_client/main.py +14 -5
- package/lark_client/session_bridge.py +0 -1
- package/lark_client/shared_memory_poller.py +159 -2
- package/package.json +1 -1
- package/server/parsers/base_parser.py +4 -1
- package/server/parsers/claude_parser.py +27 -6
- package/server/parsers/codex_parser.py +264 -115
- package/server/rich_text_renderer.py +69 -4
- package/server/server.py +195 -16
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Claude Code 只能在启动它的那个终端窗口里操作。一旦离开电
|
|
|
11
11
|
- **飞书里直接操作** — 手机/平板打开飞书,就能看到 Claude 的实时输出,发消息、选选项、批准权限,和终端里一模一样。
|
|
12
12
|
- **用手机无缝延续电脑上做的工作** — 电脑上打开的Claude进程,也可以用飞书共享操作,开会、午休、通勤、上厕所时,都可以用手机延续之前在电脑上的工作。
|
|
13
13
|
- **在电脑上也可以无缝延续手机上的工作** - 在lark端也可以打开新的Claude进程启动新的工作,回到电脑前还可以`attach`共享操作同一个Claude进程,延续手机端的工作。
|
|
14
|
-
- **多端共享操作** — 多个终端 + 飞书可以共享操作同一个claude进程,回到家里ssh登录到服务器上也可以通过`attach`继续操作在公司ssh登录到服务器上打开的claude进程操作。
|
|
14
|
+
- **多端共享操作** — 多个终端 + 飞书可以共享操作同一个claude/codex进程,回到家里ssh登录到服务器上也可以通过`attach`继续操作在公司ssh登录到服务器上打开的claude/codex进程操作。
|
|
15
15
|
- **机制安全** - 完全不侵入 Claude 进程,remote 功能完全通过终端交互来实现,不必担心 Claude 进程意外崩溃导致工作进展丢失。
|
|
16
16
|
|
|
17
17
|
## 飞书端体验
|
|
@@ -68,7 +68,7 @@ remote-claude attach <会话名>
|
|
|
68
68
|
|
|
69
69
|
1. 登录[飞书开放平台](https://open.feishu.cn/),创建企业自建应用
|
|
70
70
|
2. 获取 **App ID** 和 **App Secret**
|
|
71
|
-
3. 用`cla`或`cl`启动一次claude, 按照交互提示填入**App ID** 和 **App Secret**
|
|
71
|
+
3. 用`cla`或`cl`启动一次claude(或用cx或cdx启动一次codex), 按照交互提示填入**App ID** 和 **App Secret**
|
|
72
72
|
4. [飞书开放平台]的企业自建应用页面`添加应用能力`(机器人能力)
|
|
73
73
|
5. 企业自建应用页面配置事件回调(如果第3步没启动成功这里配置不了):
|
|
74
74
|
- `事件与回调` -> `事件配置` -> `订阅方式`右边的笔图标 -> `选择:使用长连接接收事件` -> `点击保存` -> `下面添加事件: 接收消息 v2.0 (im.message.receive_v1)`
|
|
@@ -123,6 +123,8 @@ remote-claude attach <会话名>
|
|
|
123
123
|
"im:message.p2p_msg:readonly",
|
|
124
124
|
"im:message.reactions:read",
|
|
125
125
|
"im:message.reactions:write_only",
|
|
126
|
+
"im:message.urgent",
|
|
127
|
+
"im:message.urgent.status:write",
|
|
126
128
|
"im:message:readonly",
|
|
127
129
|
"im:message:recall",
|
|
128
130
|
"im:message:send_as_bot",
|
|
@@ -198,9 +200,9 @@ remote-claude attach <会话名>
|
|
|
198
200
|
7. 企业自建应用页面: `创建版本` -> `发布到线上`
|
|
199
201
|
8. 至此,完成飞书机器人配置
|
|
200
202
|
|
|
201
|
-
#### 4.2 通过飞书机器人操作claude
|
|
203
|
+
#### 4.2 通过飞书机器人操作claude/codex
|
|
202
204
|
|
|
203
|
-
1.
|
|
205
|
+
1. 从飞书搜索刚刚创建的飞书机器人(第一次搜比较慢,如果搜不到可能是忘记发布了)
|
|
204
206
|
2. 飞书中与机器人对话,可用命令:
|
|
205
207
|
- `/menu` 展示菜单卡片,后续操作都操作这个卡片上的按钮即可
|
|
206
208
|
|
package/init.sh
CHANGED
|
@@ -40,6 +40,11 @@ setup_path() {
|
|
|
40
40
|
# 不存在则创建
|
|
41
41
|
[[ -f "$PROFILE" ]] || touch "$PROFILE"
|
|
42
42
|
|
|
43
|
+
# 未写入 source .bashrc 则追加
|
|
44
|
+
if ! grep -qF '.bashrc' "$PROFILE" 2>/dev/null; then
|
|
45
|
+
echo '[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"' >> "$PROFILE"
|
|
46
|
+
fi
|
|
47
|
+
|
|
43
48
|
# 未写入则追加
|
|
44
49
|
if ! grep -qF '$HOME/.local/bin' "$PROFILE" 2>/dev/null; then
|
|
45
50
|
echo "" >> "$PROFILE"
|
|
@@ -1201,7 +1201,8 @@ def build_session_closed_card(session_name: str) -> Dict[str, Any]:
|
|
|
1201
1201
|
|
|
1202
1202
|
|
|
1203
1203
|
def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
|
|
1204
|
-
session_groups: Optional[Dict[str, str]] = None, page: int = 0
|
|
1204
|
+
session_groups: Optional[Dict[str, str]] = None, page: int = 0,
|
|
1205
|
+
notify_enabled: bool = True, urgent_enabled: bool = False) -> Dict[str, Any]:
|
|
1205
1206
|
"""构建快捷操作菜单卡片(/menu 和 /list 共用):内嵌会话列表 + 快捷操作"""
|
|
1206
1207
|
elements = []
|
|
1207
1208
|
|
|
@@ -1251,6 +1252,55 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
|
|
|
1251
1252
|
]
|
|
1252
1253
|
})
|
|
1253
1254
|
|
|
1255
|
+
notify_label = "🔔 完成通知: 开" if notify_enabled else "🔕 完成通知: 关"
|
|
1256
|
+
if not notify_enabled:
|
|
1257
|
+
urgent_label = "🔇 加急通知: 关"
|
|
1258
|
+
urgent_button: Dict[str, Any] = {
|
|
1259
|
+
"tag": "button",
|
|
1260
|
+
"text": {"tag": "plain_text", "content": urgent_label},
|
|
1261
|
+
"type": "default",
|
|
1262
|
+
"disabled": True,
|
|
1263
|
+
}
|
|
1264
|
+
elif urgent_enabled:
|
|
1265
|
+
urgent_label = "🔔 加急通知: 开"
|
|
1266
|
+
urgent_button = {
|
|
1267
|
+
"tag": "button",
|
|
1268
|
+
"text": {"tag": "plain_text", "content": urgent_label},
|
|
1269
|
+
"type": "default",
|
|
1270
|
+
"behaviors": [{"type": "callback", "value": {"action": "menu_toggle_urgent"}}]
|
|
1271
|
+
}
|
|
1272
|
+
else:
|
|
1273
|
+
urgent_label = "🔕 加急通知: 关"
|
|
1274
|
+
urgent_button = {
|
|
1275
|
+
"tag": "button",
|
|
1276
|
+
"text": {"tag": "plain_text", "content": urgent_label},
|
|
1277
|
+
"type": "default",
|
|
1278
|
+
"behaviors": [{"type": "callback", "value": {"action": "menu_toggle_urgent"}}]
|
|
1279
|
+
}
|
|
1280
|
+
elements.append({
|
|
1281
|
+
"tag": "column_set",
|
|
1282
|
+
"flex_mode": "none",
|
|
1283
|
+
"columns": [
|
|
1284
|
+
{
|
|
1285
|
+
"tag": "column",
|
|
1286
|
+
"width": "weighted",
|
|
1287
|
+
"weight": 1,
|
|
1288
|
+
"elements": [{
|
|
1289
|
+
"tag": "button",
|
|
1290
|
+
"text": {"tag": "plain_text", "content": notify_label},
|
|
1291
|
+
"type": "default",
|
|
1292
|
+
"behaviors": [{"type": "callback", "value": {"action": "menu_toggle_notify"}}]
|
|
1293
|
+
}]
|
|
1294
|
+
},
|
|
1295
|
+
{
|
|
1296
|
+
"tag": "column",
|
|
1297
|
+
"width": "weighted",
|
|
1298
|
+
"weight": 1,
|
|
1299
|
+
"elements": [urgent_button]
|
|
1300
|
+
},
|
|
1301
|
+
]
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1254
1304
|
return {
|
|
1255
1305
|
"schema": "2.0",
|
|
1256
1306
|
"config": {"wide_screen_mode": True},
|
|
@@ -223,11 +223,69 @@ class CardService:
|
|
|
223
223
|
_track_stats('error', 'card_api', detail='update_card')
|
|
224
224
|
return False
|
|
225
225
|
|
|
226
|
-
async def
|
|
227
|
-
"""
|
|
226
|
+
async def send_urgent_app(self, message_id: str, user_ids: list) -> bool:
|
|
227
|
+
"""对已有消息发送应用内加急通知,避免发新消息顶高流式卡片"""
|
|
228
|
+
if not self.client:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
import asyncio
|
|
232
|
+
from lark_oapi.api.im.v1 import UrgentAppMessageRequest, UrgentReceivers
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
request = UrgentAppMessageRequest.builder() \
|
|
236
|
+
.message_id(message_id) \
|
|
237
|
+
.user_id_type("open_id") \
|
|
238
|
+
.request_body(
|
|
239
|
+
UrgentReceivers.builder()
|
|
240
|
+
.user_id_list(user_ids)
|
|
241
|
+
.build()
|
|
242
|
+
) \
|
|
243
|
+
.build()
|
|
244
|
+
|
|
245
|
+
response = await asyncio.to_thread(self.client.im.v1.message.urgent_app, request)
|
|
246
|
+
if response.success():
|
|
247
|
+
logger.info(f"加急通知成功: message_id={message_id}, users={user_ids}")
|
|
248
|
+
return True
|
|
249
|
+
else:
|
|
250
|
+
logger.warning(f"加急通知失败: code={response.code} msg={response.msg}")
|
|
251
|
+
return False
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.error(f"加急通知异常: {e}")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
async def cancel_urgent_app(self, message_id: str, user_ids: list) -> bool:
|
|
257
|
+
"""取消已有消息的应用内加急通知"""
|
|
258
|
+
if not self.client:
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
import asyncio
|
|
262
|
+
from lark_oapi.core.model import BaseRequest
|
|
263
|
+
from lark_oapi.core.enum import HttpMethod, AccessTokenType
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
request = BaseRequest()
|
|
267
|
+
request.http_method = HttpMethod.POST
|
|
268
|
+
request.uri = "/open-apis/im/v2/urgent/batch_cancel"
|
|
269
|
+
request.token_types = {AccessTokenType.TENANT}
|
|
270
|
+
request.queries = [("user_id_type", "open_id")]
|
|
271
|
+
request.body = {"message_id": message_id, "receiver_user_ids": user_ids}
|
|
272
|
+
|
|
273
|
+
response = await asyncio.to_thread(self.client.request, request)
|
|
274
|
+
if response.success():
|
|
275
|
+
logger.info(f"取消加急成功: message_id={message_id}, code={response.code}")
|
|
276
|
+
return True
|
|
277
|
+
else:
|
|
278
|
+
logger.warning(f"取消加急失败: code={response.code} msg={response.msg}")
|
|
279
|
+
return False
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.error(f"取消加急异常: {e}")
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
async def send_text(self, chat_id: str, text: str) -> Optional[str]:
|
|
285
|
+
"""发送纯文本消息,返回 message_id(失败返回 None)"""
|
|
228
286
|
if not self.client:
|
|
229
287
|
print(f"[Lark] 消息: {text}")
|
|
230
|
-
return
|
|
288
|
+
return None
|
|
231
289
|
|
|
232
290
|
try:
|
|
233
291
|
import asyncio
|
|
@@ -247,10 +305,14 @@ class CardService:
|
|
|
247
305
|
self.client.im.v1.message.create, request
|
|
248
306
|
)
|
|
249
307
|
|
|
250
|
-
if
|
|
308
|
+
if response.success():
|
|
309
|
+
return getattr(getattr(response, "data", None), "message_id", None)
|
|
310
|
+
else:
|
|
251
311
|
logger.warning(f"发送文本失败: code={response.code} msg={response.msg}")
|
|
312
|
+
return None
|
|
252
313
|
except Exception as e:
|
|
253
314
|
logger.error(f"发送文本异常: {e}")
|
|
315
|
+
return None
|
|
254
316
|
|
|
255
317
|
# 管理活跃卡片的方法
|
|
256
318
|
def get_active_card(self, chat_id: str) -> Optional[CardState]:
|
|
@@ -115,7 +115,8 @@ class LarkHandler:
|
|
|
115
115
|
|
|
116
116
|
# ── 统一 attach / detach / on_disconnect ────────────────────────────────
|
|
117
117
|
|
|
118
|
-
async def _attach(self, chat_id: str, session_name: str
|
|
118
|
+
async def _attach(self, chat_id: str, session_name: str,
|
|
119
|
+
user_id: Optional[str] = None) -> bool:
|
|
119
120
|
"""统一 attach 逻辑(私聊/群聊共用)"""
|
|
120
121
|
# 在断开旧连接之前,先更新旧流式卡片为已断开状态
|
|
121
122
|
old_session = self._chat_sessions.get(chat_id)
|
|
@@ -138,7 +139,8 @@ class LarkHandler:
|
|
|
138
139
|
if await bridge.connect():
|
|
139
140
|
self._bridges[chat_id] = bridge
|
|
140
141
|
self._chat_sessions[chat_id] = session_name
|
|
141
|
-
self._poller.start(chat_id, session_name)
|
|
142
|
+
self._poller.start(chat_id, session_name, is_group=(chat_id in self._group_chat_ids),
|
|
143
|
+
notify_user_id=user_id)
|
|
142
144
|
_track_stats('lark', 'attach', session_name=session_name,
|
|
143
145
|
chat_id=chat_id)
|
|
144
146
|
return True
|
|
@@ -244,7 +246,7 @@ class LarkHandler:
|
|
|
244
246
|
)
|
|
245
247
|
return
|
|
246
248
|
|
|
247
|
-
ok = await self._attach(chat_id, session_name)
|
|
249
|
+
ok = await self._attach(chat_id, session_name, user_id=user_id)
|
|
248
250
|
if ok:
|
|
249
251
|
self._chat_bindings[chat_id] = session_name
|
|
250
252
|
self._save_chat_bindings()
|
|
@@ -374,7 +376,7 @@ class LarkHandler:
|
|
|
374
376
|
await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
|
|
375
377
|
return
|
|
376
378
|
|
|
377
|
-
ok = await self._attach(chat_id, session_name)
|
|
379
|
+
ok = await self._attach(chat_id, session_name, user_id=user_id)
|
|
378
380
|
if ok:
|
|
379
381
|
self._chat_bindings[chat_id] = session_name
|
|
380
382
|
self._save_chat_bindings()
|
|
@@ -603,9 +605,25 @@ class LarkHandler:
|
|
|
603
605
|
for cid in self._group_chat_ids
|
|
604
606
|
if cid in self._chat_bindings
|
|
605
607
|
}
|
|
606
|
-
card = build_menu_card(sessions, current_session=current, session_groups=session_groups, page=page
|
|
608
|
+
card = build_menu_card(sessions, current_session=current, session_groups=session_groups, page=page,
|
|
609
|
+
notify_enabled=self._poller.get_notify_enabled(),
|
|
610
|
+
urgent_enabled=self._poller.get_urgent_enabled())
|
|
607
611
|
await self._send_or_update_card(chat_id, card, message_id)
|
|
608
612
|
|
|
613
|
+
async def _cmd_toggle_notify(self, user_id: str, chat_id: str,
|
|
614
|
+
message_id: Optional[str] = None):
|
|
615
|
+
"""切换就绪通知开关并刷新菜单卡片"""
|
|
616
|
+
new_value = not self._poller.get_notify_enabled()
|
|
617
|
+
self._poller.set_notify_enabled(new_value)
|
|
618
|
+
await self._cmd_menu(user_id, chat_id, message_id=message_id)
|
|
619
|
+
|
|
620
|
+
async def _cmd_toggle_urgent(self, user_id: str, chat_id: str,
|
|
621
|
+
message_id: Optional[str] = None):
|
|
622
|
+
"""切换加急通知开关并刷新菜单卡片"""
|
|
623
|
+
new_value = not self._poller.get_urgent_enabled()
|
|
624
|
+
self._poller.set_urgent_enabled(new_value)
|
|
625
|
+
await self._cmd_menu(user_id, chat_id, message_id=message_id)
|
|
626
|
+
|
|
609
627
|
async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
|
|
610
628
|
tree: bool = False, message_id: Optional[str] = None, page: int = 0):
|
|
611
629
|
"""查看目录文件结构"""
|
|
@@ -715,7 +733,7 @@ class LarkHandler:
|
|
|
715
733
|
self._group_chat_ids.add(group_chat_id)
|
|
716
734
|
self._save_group_chat_ids()
|
|
717
735
|
# 立即 attach,让新群即刻开始接收 Claude 输出
|
|
718
|
-
await self._attach(group_chat_id, session_name)
|
|
736
|
+
await self._attach(group_chat_id, session_name, user_id=user_id)
|
|
719
737
|
|
|
720
738
|
# 刷新会话列表卡片,使"创建群聊"按钮变为"进入群聊"
|
|
721
739
|
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
@@ -804,7 +822,7 @@ class LarkHandler:
|
|
|
804
822
|
saved_session = self._chat_bindings.get(chat_id)
|
|
805
823
|
if saved_session:
|
|
806
824
|
logger.info(f"自动恢复绑定: chat_id={chat_id[:8]}..., session={saved_session}")
|
|
807
|
-
ok = await self._attach(chat_id, saved_session)
|
|
825
|
+
ok = await self._attach(chat_id, saved_session, user_id=user_id)
|
|
808
826
|
if not ok:
|
|
809
827
|
self._group_chat_ids.discard(chat_id)
|
|
810
828
|
self._save_group_chat_ids()
|
package/lark_client/main.py
CHANGED
|
@@ -54,11 +54,12 @@ def _setup_logging():
|
|
|
54
54
|
for _noisy in ('urllib3', 'websockets', 'asyncio'):
|
|
55
55
|
logging.getLogger(_noisy).setLevel(logging.INFO)
|
|
56
56
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
# 控制台输出(仅在终端交互模式下启用,守护进程模式下 stderr 已重定向到日志文件)
|
|
58
|
+
if sys.stderr.isatty():
|
|
59
|
+
console_handler = logging.StreamHandler()
|
|
60
|
+
console_handler.setLevel(logging.INFO)
|
|
61
|
+
console_handler.setFormatter(formatter)
|
|
62
|
+
root_logger.addHandler(console_handler)
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
# 在导入 lark SDK 之前配置日志
|
|
@@ -305,6 +306,14 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
|
|
|
305
306
|
asyncio.create_task(_multi_send())
|
|
306
307
|
return None
|
|
307
308
|
|
|
309
|
+
if action_type == "menu_toggle_notify":
|
|
310
|
+
asyncio.create_task(handler._cmd_toggle_notify(user_id, chat_id, message_id=message_id))
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
if action_type == "menu_toggle_urgent":
|
|
314
|
+
asyncio.create_task(handler._cmd_toggle_urgent(user_id, chat_id, message_id=message_id))
|
|
315
|
+
return None
|
|
316
|
+
|
|
308
317
|
# 各卡片底部菜单按钮:辅助卡片就地→菜单,流式卡片降级新卡
|
|
309
318
|
if action_type == "menu_open":
|
|
310
319
|
asyncio.create_task(handler._cmd_menu(user_id, chat_id, message_id=message_id))
|
|
@@ -11,7 +11,6 @@ import sys
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Optional, Callable, Dict
|
|
13
13
|
|
|
14
|
-
logging.basicConfig(level=logging.INFO, format='[%(name)s] %(message)s')
|
|
15
14
|
logger = logging.getLogger('SessionBridge')
|
|
16
15
|
|
|
17
16
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
@@ -35,6 +35,8 @@ try:
|
|
|
35
35
|
except Exception:
|
|
36
36
|
def _track_stats(*args, **kwargs): pass
|
|
37
37
|
|
|
38
|
+
from utils.session import ensure_user_data_dir, USER_DATA_DIR
|
|
39
|
+
|
|
38
40
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
39
41
|
INITIAL_WINDOW = 30 # 首次 attach 最多显示最近 30 个 blocks
|
|
40
42
|
from .config import MAX_CARD_BLOCKS # 单张卡片最多 N 个 blocks → 超限冻结(可通过 .env 配置)
|
|
@@ -62,6 +64,10 @@ class StreamTracker:
|
|
|
62
64
|
cards: List[CardSlice] = field(default_factory=list)
|
|
63
65
|
content_hash: str = ""
|
|
64
66
|
reader: Optional[Any] = None # SharedStateReader,延迟初始化
|
|
67
|
+
is_group: bool = False # 是否为群聊
|
|
68
|
+
prev_is_ready: bool = True # 上一帧是否就绪(初始 True 避免首次误触发)
|
|
69
|
+
notify_user_id: Optional[str] = None # 就绪通知 @ 的用户 open_id
|
|
70
|
+
last_notify_message_id: Optional[str] = None # 上一条就绪通知的 message_id(用于后续加急复用)
|
|
65
71
|
|
|
66
72
|
|
|
67
73
|
# ── 轮询器 ────────────────────────────────────────────────────────────────────
|
|
@@ -81,11 +87,13 @@ class SharedMemoryPoller:
|
|
|
81
87
|
self._kick_events: Dict[str, asyncio.Event] = {} # chat_id → Event(唤醒轮询)
|
|
82
88
|
self._rapid_until: Dict[str, float] = {} # chat_id → 快速模式截止时间
|
|
83
89
|
|
|
84
|
-
def start(self, chat_id: str, session_name: str
|
|
90
|
+
def start(self, chat_id: str, session_name: str, is_group: bool = False,
|
|
91
|
+
notify_user_id: Optional[str] = None) -> None:
|
|
85
92
|
"""attach 成功后调用:清空旧状态,启动轮询 Task"""
|
|
86
93
|
self.stop(chat_id)
|
|
87
94
|
|
|
88
|
-
tracker = StreamTracker(chat_id=chat_id, session_name=session_name
|
|
95
|
+
tracker = StreamTracker(chat_id=chat_id, session_name=session_name, is_group=is_group,
|
|
96
|
+
notify_user_id=notify_user_id)
|
|
89
97
|
self._trackers[chat_id] = tracker
|
|
90
98
|
self._kick_events[chat_id] = asyncio.Event()
|
|
91
99
|
|
|
@@ -212,6 +220,9 @@ class SharedMemoryPoller:
|
|
|
212
220
|
option_block = state.get("option_block")
|
|
213
221
|
cli_type = state.get("cli_type", "claude")
|
|
214
222
|
|
|
223
|
+
# 就绪状态转换检测
|
|
224
|
+
await self._check_ready_notification(tracker, blocks, status_line, option_block, cli_type)
|
|
225
|
+
|
|
215
226
|
# 获取活跃卡片(最后一张且未冻结)
|
|
216
227
|
active = None
|
|
217
228
|
if tracker.cards and not tracker.cards[-1].frozen:
|
|
@@ -415,6 +426,84 @@ class SharedMemoryPoller:
|
|
|
415
426
|
f"blocks={len(new_blocks)} card_id={new_card_id}"
|
|
416
427
|
)
|
|
417
428
|
|
|
429
|
+
async def _check_ready_notification(
|
|
430
|
+
self, tracker: StreamTracker,
|
|
431
|
+
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)
|
|
436
|
+
prev_ready = tracker.prev_is_ready
|
|
437
|
+
tracker.prev_is_ready = current_ready
|
|
438
|
+
|
|
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:
|
|
471
|
+
msg_id = await self._card_service.send_text(tracker.chat_id, text)
|
|
472
|
+
if msg_id:
|
|
473
|
+
tracker.last_notify_message_id = msg_id
|
|
474
|
+
except Exception as e:
|
|
475
|
+
logger.warning(f"就绪提醒发送失败: {e}")
|
|
476
|
+
|
|
477
|
+
async def _cancel_urgent_later(self, message_id: str, user_ids: list, delay: float = 15) -> None:
|
|
478
|
+
"""延迟取消加急通知"""
|
|
479
|
+
await asyncio.sleep(delay)
|
|
480
|
+
try:
|
|
481
|
+
await self._card_service.cancel_urgent_app(message_id, user_ids)
|
|
482
|
+
except Exception as e:
|
|
483
|
+
logger.warning(f"延迟取消加急失败: {e}")
|
|
484
|
+
|
|
485
|
+
def get_notify_enabled(self) -> bool:
|
|
486
|
+
"""获取就绪通知开关状态"""
|
|
487
|
+
return _notify_enabled
|
|
488
|
+
|
|
489
|
+
def set_notify_enabled(self, enabled: bool) -> None:
|
|
490
|
+
"""更新就绪通知开关状态并持久化"""
|
|
491
|
+
global _notify_enabled
|
|
492
|
+
_notify_enabled = enabled
|
|
493
|
+
_save_notify_enabled(enabled)
|
|
494
|
+
logger.info(f"就绪通知开关已{'开启' if enabled else '关闭'}")
|
|
495
|
+
|
|
496
|
+
def get_urgent_enabled(self) -> bool:
|
|
497
|
+
"""获取加急通知开关状态"""
|
|
498
|
+
return _urgent_enabled
|
|
499
|
+
|
|
500
|
+
def set_urgent_enabled(self, enabled: bool) -> None:
|
|
501
|
+
"""更新加急通知开关状态并持久化"""
|
|
502
|
+
global _urgent_enabled
|
|
503
|
+
_urgent_enabled = enabled
|
|
504
|
+
_save_urgent_enabled(enabled)
|
|
505
|
+
logger.info(f"加急通知开关已{'开启' if enabled else '关闭'}")
|
|
506
|
+
|
|
418
507
|
@staticmethod
|
|
419
508
|
def _compute_hash(
|
|
420
509
|
blocks: list, status_line: Optional[dict],
|
|
@@ -432,3 +521,71 @@ class SharedMemoryPoller:
|
|
|
432
521
|
return hashlib.md5(
|
|
433
522
|
json.dumps(data, ensure_ascii=False, sort_keys=True).encode()
|
|
434
523
|
).hexdigest()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[dict]) -> bool:
|
|
529
|
+
"""数据层就绪判断:无 streaming block、无 status_line(option_block 不影响就绪)"""
|
|
530
|
+
has_streaming = any(b.get("is_streaming", False) for b in blocks)
|
|
531
|
+
return not has_streaming and status_line is None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
_READY_COUNT_FILE = USER_DATA_DIR / "ready_notify_count"
|
|
535
|
+
_NOTIFY_ENABLED_FILE = USER_DATA_DIR / "ready_notify_enabled"
|
|
536
|
+
_URGENT_ENABLED_FILE = USER_DATA_DIR / "urgent_notify_enabled"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _load_notify_enabled() -> bool:
|
|
540
|
+
"""读取就绪通知开关状态,不存在或解析失败返回 True(默认开启)"""
|
|
541
|
+
try:
|
|
542
|
+
return _NOTIFY_ENABLED_FILE.read_text().strip() == "1"
|
|
543
|
+
except Exception:
|
|
544
|
+
return True
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _save_notify_enabled(enabled: bool) -> None:
|
|
548
|
+
"""持久化就绪通知开关状态"""
|
|
549
|
+
try:
|
|
550
|
+
ensure_user_data_dir()
|
|
551
|
+
_NOTIFY_ENABLED_FILE.write_text("1" if enabled else "0")
|
|
552
|
+
except Exception as e:
|
|
553
|
+
logger.warning(f"_save_notify_enabled 失败: {e}")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _load_urgent_enabled() -> bool:
|
|
557
|
+
"""读取加急通知开关状态,不存在或解析失败返回 False(默认关闭)"""
|
|
558
|
+
try:
|
|
559
|
+
return _URGENT_ENABLED_FILE.read_text().strip() == "1"
|
|
560
|
+
except Exception:
|
|
561
|
+
return False
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _save_urgent_enabled(enabled: bool) -> None:
|
|
565
|
+
"""持久化加急通知开关状态"""
|
|
566
|
+
try:
|
|
567
|
+
ensure_user_data_dir()
|
|
568
|
+
_URGENT_ENABLED_FILE.write_text("1" if enabled else "0")
|
|
569
|
+
except Exception as e:
|
|
570
|
+
logger.warning(f"_save_urgent_enabled 失败: {e}")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# 模块级开关状态:启动时加载一次
|
|
574
|
+
_notify_enabled: bool = _load_notify_enabled()
|
|
575
|
+
_urgent_enabled: bool = _load_urgent_enabled()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _increment_ready_count() -> int:
|
|
579
|
+
"""原子递增全局就绪提醒计数器,返回新值(持久化到文件)"""
|
|
580
|
+
try:
|
|
581
|
+
ensure_user_data_dir()
|
|
582
|
+
try:
|
|
583
|
+
count = int(_READY_COUNT_FILE.read_text().strip())
|
|
584
|
+
except Exception:
|
|
585
|
+
count = 0
|
|
586
|
+
count += 1
|
|
587
|
+
_READY_COUNT_FILE.write_text(str(count))
|
|
588
|
+
return count
|
|
589
|
+
except Exception as e:
|
|
590
|
+
logger.warning(f"_increment_ready_count 失败: {e}")
|
|
591
|
+
return 1
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ ClaudeParser 和 CodexParser 均继承此类。
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from abc import ABC, abstractmethod
|
|
8
|
-
from typing import List
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
9
|
|
|
10
10
|
import pyte
|
|
11
11
|
|
|
@@ -29,6 +29,9 @@ class BaseParser(ABC):
|
|
|
29
29
|
last_input_ansi_text: str = ''
|
|
30
30
|
last_parse_timing: str = ''
|
|
31
31
|
last_layout_mode: str = 'normal'
|
|
32
|
+
# 诊断用:记录最近一次 parse 的区域统计与组件统计
|
|
33
|
+
last_region_stats: Dict[str, Any] = None
|
|
34
|
+
last_component_stats: Dict[str, Any] = None
|
|
32
35
|
|
|
33
36
|
@abstractmethod
|
|
34
37
|
def parse(self, screen: pyte.Screen) -> List:
|
|
@@ -909,22 +909,43 @@ class ClaudeParser(BaseParser):
|
|
|
909
909
|
return None
|
|
910
910
|
|
|
911
911
|
def _extract_input_area_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
|
|
912
|
-
"""提取输入区 ❯
|
|
913
|
-
|
|
912
|
+
"""提取输入区 ❯ 提示符后的当前输入文本(空提示符返回空字符串)。
|
|
913
|
+
多行输入时,收集 ❯ 行之后首列为空的续行,合并为完整文本。
|
|
914
|
+
"""
|
|
915
|
+
for i, row in enumerate(input_rows):
|
|
914
916
|
if _get_col0(screen, row) == '❯':
|
|
915
917
|
text = _get_row_text(screen, row)[1:].strip()
|
|
916
918
|
# 排除纯分割线装饰行(如 ❯─────)
|
|
917
919
|
if text and not all(c in DIVIDER_CHARS for c in text):
|
|
918
|
-
|
|
920
|
+
# 收集续行(首列为空的非空文本行)
|
|
921
|
+
lines = [text]
|
|
922
|
+
for next_row in input_rows[i + 1:]:
|
|
923
|
+
col0 = _get_col0(screen, next_row)
|
|
924
|
+
if col0.strip(): # 首列有字符,遇到新 block 或新 ❯ 行,停止
|
|
925
|
+
break
|
|
926
|
+
next_text = _get_row_text(screen, next_row).strip()
|
|
927
|
+
if next_text:
|
|
928
|
+
lines.append(next_text)
|
|
929
|
+
return '\n'.join(lines)
|
|
919
930
|
return ''
|
|
920
931
|
|
|
921
932
|
def _extract_input_area_ansi_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
|
|
922
|
-
"""提取输入区 ❯ 提示符后的当前输入文本(ANSI
|
|
923
|
-
|
|
933
|
+
"""提取输入区 ❯ 提示符后的当前输入文本(ANSI 版本)。
|
|
934
|
+
多行输入时,收集 ❯ 行之后首列为空的续行,合并为完整文本。
|
|
935
|
+
"""
|
|
936
|
+
for i, row in enumerate(input_rows):
|
|
924
937
|
if _get_col0(screen, row) == '❯':
|
|
925
938
|
text = _get_row_text(screen, row)[1:].strip()
|
|
926
939
|
if text and not all(c in DIVIDER_CHARS for c in text):
|
|
927
|
-
|
|
940
|
+
lines = [_get_row_ansi_text(screen, row, start_col=1).strip()]
|
|
941
|
+
for next_row in input_rows[i + 1:]:
|
|
942
|
+
col0 = _get_col0(screen, next_row)
|
|
943
|
+
if col0.strip(): # 首列有字符,停止
|
|
944
|
+
break
|
|
945
|
+
next_text = _get_row_text(screen, next_row).strip()
|
|
946
|
+
if next_text:
|
|
947
|
+
lines.append(_get_row_ansi_text(screen, next_row).strip())
|
|
948
|
+
return '\n'.join(lines)
|
|
928
949
|
return ''
|
|
929
950
|
|
|
930
951
|
def _parse_permission_area(
|