Claude Code 任务完成通知实战:SSH 远程服务器与本地环境接入飞书机器人

最近在使用 Claude Code 时,我遇到了一个非常实际的问题:

Claude Code 很适合处理多步任务,但无论是在 SSH 远程服务器上运行,还是在本地终端中运行,只要任务稍微长一点,就不得不一直盯着终端窗口,等待它结束、等待它请求授权,或者等待它回到需要我输入下一步指令的状态。

这显然不够高效。

于是,我开始折腾一套“Claude Code → Hook → Python 脚本 → 飞书机器人”的通知链路,希望在下面几种场景中自动收到消息:

  • Claude 完成一轮响应;
  • Claude 做完当前工作,开始等待我下一步输入;
  • Claude 需要我授权;
  • Claude 主动向我发起追问或确认。

整个过程看上去只是“发个 webhook”这么简单,但真正做下来,踩坑并不少:

  • 一开始误以为只要配置 Notification,Claude 每次回复结束后都会通知;
  • 飞书 webhook 地址曾经写错,前缀重复导致直接 404
  • SSH 场景下服务器开了 Clash 代理,hook 脚本偶发报 Connection reset by peer
  • Windows 本地路径因为反斜杠转义,被 shell 解析坏掉;
  • 本地 hook 执行时还遇到了 utf-8 codec can't encode surrogate 这类编码问题。

最后,我把两套环境都完全跑通了:

  1. SSH 远程 Linux 服务器运行 Claude Code → 飞书通知
  2. 本地 Windows 直接运行 Claude Code → 飞书通知

这篇文章把整个过程完整整理一遍,尽量写成一份可以直接照着配置的实战记录。


一、先说结论:Claude Code 通知到底该怎么理解

Claude Code 的通知核心并不是终端弹窗,而是 hooks

在这次实践里,最重要的是两个事件:

1. Stop

Stop 表示:

Claude 完成一轮响应时触发。

它更适合“我给 Claude 发了一条指令,Claude 回答完以后就通知我”的场景。

如果你的目标是:

  • 我发一句话;
  • Claude 回复结束;
  • 然后飞书提醒我;

那么核心应该配的是 Stop

2. Notification

Notification 并不是“每次回复都触发”,而是:

Claude 进入需要你注意的状态时触发。

常见包括:

  • idle_prompt:当前工作完成,开始等待你下一步输入;
  • permission_prompt:需要你授权;
  • elicitation_dialog:Claude 主动向你提问;
  • auth_success:认证相关事件完成。

也就是说,如果你只配置 Notification,然后输入一句简单的 hello,Claude 回复结束后通常不会立即通知。因为它不等于“回复完成”,它更像“现在需要你注意”。

3. 最推荐的配置方式

如果希望通知尽量完整,我建议:

  • Stop:负责“每轮响应结束”;
  • Notification:负责“需要你注意”。

如果你希望飞书安静一点,可以只保留 Stop;如果你只关心“真正需要我介入”的时刻,可以只保留 Notification


二、整体方案:Claude Code → Hook → 飞书机器人

我最后采用的整体方案非常简单:

graph LR
  Claude[Claude Code] --> Hook[Hook 触发]
  Hook --> Python[notify_feishu.py]
  Python --> Feishu[飞书机器人 Webhook]
  Feishu --> User[收到通知]

也就是说:

  1. Claude Code 在指定时机触发 hook;
  2. hook 调用本地命令;
  3. 本地命令执行 Python 脚本;
  4. Python 脚本向飞书机器人发送文本消息;
  5. 飞书群收到提醒。

这个思路在 SSH 远程 Linux本地 Windows 上都成立,只是路径、环境变量和代理处理略有区别。


三、飞书机器人准备工作

无论你是在远程服务器上配,还是在本地机器上配,飞书机器人都要先准备好。

1. 在飞书群中添加自定义机器人

进入你的飞书群,添加一个 自定义机器人,然后记录两项信息:

  • Webhook
  • Secret(如果你启用了签名校验)

最终拿到的 webhook 地址通常是这种格式:

1
https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

2. 一个非常容易踩的坑:Webhook 前缀重复

我一开始把环境变量写成了下面这样:

1
export FEISHU_WEBHOOK='https://open.feishu.cn/open-apis/bot/v2/hook/https://open.feishu.cn/open-apis/bot/v2/hook/da4fdb04-0be6-4cff-972c-da029f870919'

这会直接导致:

1
HTTP Error 404: Not Found

因为 webhook 的前缀被写了两遍。

正确写法应该是:

1
2
export FEISHU_WEBHOOK='https://open.feishu.cn/open-apis/bot/v2/hook/da4fdb04-0be6-4cff-972c-da029f870919'
export FEISHU_SECRET='你的secret'

因此,如果你一开始就收到 404,最先检查的不是 Claude 配置,也不是 Python 逻辑,而是:

Webhook 地址是否写对。


四、SSH 远程服务器场景:Linux 上让 Claude Code 自动发飞书通知

这是最有价值的场景之一,因为真正需要通知的,往往就是挂在服务器上跑的任务。

典型使用方式如下:

  • 本地通过 SSH 连接远程 Linux;
  • 在远程服务器上启动 Claude Code;
  • Claude 做长任务时不再盯着终端;
  • 通过飞书获取任务完成、等待输入、权限请求等提醒。

1. 推荐目录结构

我最后统一放在下面这些路径中:

1
2
3
4
~/.claude/
~/.claude/hooks/
~/.claude/hooks/notify_feishu.py
~/.claude/settings.json

创建目录:

1
mkdir -p ~/.claude/hooks

2. 在远程 Linux 上配置飞书环境变量

我把飞书 webhook 和 secret 写入当前用户的 ~/.bashrc

1
2
export FEISHU_WEBHOOK='https://open.feishu.cn/open-apis/bot/v2/hook/你的token'
export FEISHU_SECRET='你的secret'

然后执行:

1
source ~/.bashrc

检查是否生效:

1
2
3
4
5
python3 - <<'PY'
import os
print("FEISHU_WEBHOOK =", repr(os.environ.get("FEISHU_WEBHOOK", "")))
print("FEISHU_SECRET =", repr(os.environ.get("FEISHU_SECRET", "")))
PY

如果输出里能看到正确的值,说明环境变量已经就绪。


3. 先单独测试脚本链路,而不是一上来就测 Claude

这一步非常重要。

在我整个排查过程中,最大的经验就是:

先把“Python 脚本 → 飞书”这条链路单独打通,再去接 Claude Code。

测试方法如下:

1
2
3
4
5
6
7
echo '{
"session_id": "test-session",
"cwd": "/home/shh/project",
"hook_event_name": "Stop",
"stop_hook_active": false,
"transcript_path": "/tmp/test.jsonl"
}' | python3 ~/.claude/hooks/notify_feishu.py

如果飞书群能收到消息,说明以下环节都正常:

  • webhook 正确;
  • secret 正确;
  • Python 脚本可执行;
  • 服务器到飞书的网络链路正常。

只有这一条先通了,后面出了问题你才能明确知道是 Claude hook 配置层的问题,而不是脚本本身的问题。


4. Linux 下最终可用的飞书通知脚本

我最后整理出的版本解决了几个关键问题:

  • 支持 StopNotification
  • 自动写日志;
  • 对网络错误自动重试;
  • 能处理奇怪字符,避免编码报错;
  • 最关键:显式禁用 urllib 自动继承系统代理。

这份脚本保存为:

1
~/.claude/hooks/notify_feishu.py

内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#!/usr/bin/env python3
import sys
import os
import json
import time
import hmac
import base64
import hashlib
import socket
from urllib import request

WEBHOOK = os.environ.get("FEISHU_WEBHOOK", "").strip()
SECRET = os.environ.get("FEISHU_SECRET", "").strip()
LOG_FILE = os.path.expanduser("~/.claude/hooks/notify_feishu.log")

def safe_text(x):
if x is None:
return ""
if not isinstance(x, str):
x = str(x)
return x.encode("utf-8", "backslashreplace").decode("utf-8")

def log(msg: str):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
safe_msg = safe_text(msg)
with open(LOG_FILE, "a", encoding="utf-8", errors="backslashreplace") as f:
f.write(f"[{ts}] {safe_msg}\n")

def feishu_sign(secret: str, timestamp: str) -> str:
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
return base64.b64encode(
hmac.new(string_to_sign, b"", digestmod=hashlib.sha256).digest()
).decode("utf-8")

def post_to_feishu(text: str):
if not WEBHOOK:
raise RuntimeError("FEISHU_WEBHOOK is empty")

payload = {
"msg_type": "text",
"content": {"text": safe_text(text)}
}

if SECRET:
ts = str(int(time.time()))
payload["timestamp"] = ts
payload["sign"] = feishu_sign(SECRET, ts)

data = json.dumps(payload, ensure_ascii=True).encode("utf-8")
req = request.Request(
WEBHOOK,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)

# 禁用自动代理继承,避免 Clash/系统代理造成偶发 reset
opener = request.build_opener(request.ProxyHandler({}))

last_err = None
for i in range(3):
try:
with opener.open(req, timeout=10) as resp:
body = resp.read().decode("utf-8", errors="replace")
log(f"POST success status={resp.status} body={body}")
return
except Exception as e:
last_err = e
log(f"POST failed attempt={i+1}/3 err={repr(e)}")
time.sleep(2 * (i + 1))

raise last_err

def main():
raw = sys.stdin.read()
if not raw.strip():
raise RuntimeError("No stdin JSON received")

event = json.loads(raw)

hook_event_name = safe_text(event.get("hook_event_name", ""))
cwd = safe_text(event.get("cwd", ""))
session_id = safe_text(event.get("session_id", ""))
host = safe_text(socket.gethostname())

if hook_event_name == "Stop":
content = safe_text(event.get("last_assistant_message", ""))[:500]
text = (
f"【Claude Code 远程完成】\n"
f"主机: {host}\n"
f"目录: {cwd}\n"
f"Session: {session_id}\n"
f"事件: Stop\n"
f"摘要: {content}"
)
else:
title = safe_text(event.get("title", "Claude Code"))
message = safe_text(event.get("message", ""))
ntype = safe_text(event.get("notification_type", ""))
text = (
f"【Claude Code 远程提醒】\n"
f"主机: {host}\n"
f"目录: {cwd}\n"
f"Session: {session_id}\n"
f"事件: {hook_event_name}\n"
f"类型: {ntype}\n"
f"标题: {title}\n"
f"内容: {message}"
)

log("event=" + json.dumps(event, ensure_ascii=True))
post_to_feishu(text)

if __name__ == "__main__":
try:
main()
except Exception as e:
log(f"fatal={repr(e)}")
print(f"notify_feishu.py error: {e}", file=sys.stderr)
sys.exit(1)

赋予执行权限:

1
chmod +x ~/.claude/hooks/notify_feishu.py

5. 远程 Linux 下的 settings.json

我最后推荐使用下面这份最小可用配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/notify_feishu.py"
}
]
}
],
"Notification": [
{
"matcher": "idle_prompt|permission_prompt|elicitation_dialog|auth_success",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/notify_feishu.py"
}
]
}
]
}
}

它的含义是:

  • Stop:Claude 一轮回答结束后执行脚本;
  • Notification:Claude 需要你注意时执行脚本。

如果你嫌飞书太频繁,可以只保留 Stop


6. 我在远程 Linux 上遇到的代理问题

这是整个过程中最隐蔽、也最容易误判的问题之一。

我当时在服务器上开了 Clash,环境变量大致如下:

1
2
HTTPS_PROXY=http://127.0.0.1:7890
https_proxy=http://127.0.0.1:7890

随后出现了一个非常迷惑的现象:

  • 手工执行 Python 脚本,有时成功;
  • Claude Code 触发 hook 时,偶发报错:
1
Connection reset by peer

最后排查出来,问题并不在 Claude,也不在飞书,而在于:

Python 的 urllib 默认会自动继承环境变量中的代理设置。

也就是说:

  • 你的通知脚本实际上在走 Clash 代理;
  • 这条代理链路偶发不稳定;
  • 所以 hook 在发送 webhook 的时候会随机失败。

最稳的处理方式

直接在脚本中禁用代理继承:

1
opener = request.build_opener(request.ProxyHandler({}))

这样,脚本只在“飞书通知”这个动作上直连外网,不影响你其他终端命令继续走代理。

这是我最终认为最干净、最稳定的解决方式。


7. 如何判断远程 Linux 上的 hook 已经真的生效

在 Claude Code 中输入一句简单的话,例如:

1
hello

如果 Stop hook 配置正确,你通常会看到类似:

1
Ran 1 stop hook

如果飞书没有收到消息,不要先怀疑 Claude 配置没读到,而应该先检查日志:

1
tail -n 50 ~/.claude/hooks/notify_feishu.log

如果日志中有事件记录,就说明:

  • hook 的确触发了;
  • 问题在 Python 脚本或网络;
  • 不是 settings.json 没加载。

五、本地 Windows 场景:直接运行 Claude Code 并接入飞书通知

远程 Linux 跑通之后,我又把本地 Windows 环境也完整配了一遍。

这个场景其实更舒服,因为:

  • 不需要 SSH;
  • 不需要担心服务器链路;
  • Claude Code 可以直接在本地终端运行;
  • VS Code 中的 Claude Code 也能共用同一份配置。

1. 本地目录结构

我最后统一采用下面这种结构:

1
2
3
4
C:\Users\你的用户名\.claude\
C:\Users\你的用户名\.claude\hooks\
C:\Users\你的用户名\.claude\hooks\notify_feishu.py
C:\Users\你的用户名\.claude\settings.json

创建目录:

1
New-Item -ItemType Directory -Force "$env:USERPROFILE\.claude\hooks" | Out-Null

2. 在 Windows 上配置飞书环境变量

我使用的是用户级环境变量

1
2
[Environment]::SetEnvironmentVariable('FEISHU_WEBHOOK', 'https://open.feishu.cn/open-apis/bot/v2/hook/你的token', 'User')
[Environment]::SetEnvironmentVariable('FEISHU_SECRET', '你的secret', 'User')

为了让当前 PowerShell 会话立刻可用,再补两句:

1
2
$env:FEISHU_WEBHOOK = [Environment]::GetEnvironmentVariable('FEISHU_WEBHOOK', 'User')
$env:FEISHU_SECRET = [Environment]::GetEnvironmentVariable('FEISHU_SECRET', 'User')

检查是否生效:

1
2
echo $env:FEISHU_WEBHOOK
echo $env:FEISHU_SECRET

3. Windows 下的脚本其实可以直接复用 Linux 那一版

本地 Windows 场景中,核心脚本逻辑并不需要大改。

也就是说,只要把 Linux 中那份 notify_feishu.py 放到:

1
C:\Users\pc\.claude\hooks\notify_feishu.py

通常就可以直接使用。

真正需要注意的,主要是 路径写法奇怪字符导致的编码问题


4. Windows 下最容易踩的坑:路径被 shell 解析坏了

我最初在 settings.json 中写的是:

1
"command": "py -3 C:\\Users\\pc\\.claude\\hooks\\notify_feishu.py"

结果 Claude 执行 hook 时直接报错,路径被解析成了乱七八糟的形式,例如:

1
C:\Users\pc\Userspc.claudehooksnotify_feishu.py

本质原因是:

某些 shell 在解析命令字符串时,会把反斜杠当作转义字符。

解决方法

最稳妥的方式有两种:

方法一:给完整路径加引号

1
"command": "py -3 \"C:\\Users\\pc\\.claude\\hooks\\notify_feishu.py\""

方法二:直接使用正斜杠

1
"command": "py -3 \"C:/Users/pc/.claude/hooks/notify_feishu.py\""

我最后更推荐第二种,因为它更简洁,也更不容易再出现转义问题。


5. Windows 本地推荐的 settings.json

这是我最终保留下来的本地版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "py -3 \"C:/Users/pc/.claude/hooks/notify_feishu.py\""
}
]
}
],
"Notification": [
{
"matcher": "idle_prompt|permission_prompt|elicitation_dialog|auth_success",
"hooks": [
{
"type": "command",
"command": "py -3 \"C:/Users/pc/.claude/hooks/notify_feishu.py\""
}
]
}
]
}
}

只需要把其中的用户名路径替换成你自己的即可。


6. Windows 本地的另一个坑:UTF-8 surrogate 编码报错

当路径问题解决之后,我又遇到了一个更隐蔽的错误:

1
'utf-8' codec can't encode character '\udc80' ... surrogates not allowed

这类错误的根本原因是:

Hook 输入里混入了某些不能直接按 UTF-8 写出的字符,而脚本又把它们原样写到日志或原样拼接进了文本。

我的解决方法

在脚本中增加一个安全转换函数:

1
2
3
4
5
6
def safe_text(x):
if x is None:
return ""
if not isinstance(x, str):
x = str(x)
return x.encode("utf-8", "backslashreplace").decode("utf-8")

然后:

  • 日志写文件使用 errors="backslashreplace"
  • 发给飞书的文本也先经过 safe_text()
  • json.dumps(..., ensure_ascii=True) 保证特殊字符被安全转义。

这样就能把这类本来会导致脚本崩溃的字符,变成可安全写出的转义文本。


7. Windows 本地的测试方式

第一步:先单独测脚本

1
2
3
4
5
6
7
'{
"session_id": "test-session",
"cwd": "C:\\test",
"hook_event_name": "Stop",
"stop_hook_active": false,
"last_assistant_message": "本地测试成功"
}' | py -3 "$env:USERPROFILE\.claude\hooks\notify_feishu.py"

如果飞书群收到消息,说明:

  • Python 环境正常;
  • webhook 和 secret 正确;
  • 脚本可以独立跑通。

第二步:再测 Claude Code

重新打开一个终端,启动:

1
claude

然后输入:

1
hello

如果 Stop hook 配对,Claude 回复结束后就会触发飞书通知。


六、VS Code 中的 Claude Code 是否也能复用这套通知配置

可以,而且这是我认为这套方案很舒服的一点。

本地环境中:

Claude Code CLI 和 VS Code 中的 Claude Code 扩展,默认共用同一份 ~/.claude/settings.json 配置。

这意味着:

  • 你在本地终端里跑 Claude Code,飞书通知会触发;
  • 你在 VS Code 中使用 Claude Code,同样也能触发同样的 hook;
  • 你不需要额外为 VS Code 单独维护一份通知逻辑。

因此,本地只需要维护一份:

  • notify_feishu.py
  • ~/.claude/settings.json

就足够了。


七、我最后的推荐配置思路

1. 远程 Linux

适合:

  • SSH 跑长任务;
  • 不想一直盯终端;
  • 希望任务完成、等待输入或权限请求时飞书提醒。

推荐:

  • 配置 Stop + Notification
  • Python 脚本中显式禁用代理继承;
  • 开启日志;
  • 增加简单重试。

2. 本地 Windows

适合:

  • 本地终端运行 Claude Code;
  • 本地 VS Code 扩展一起复用;
  • 统一使用飞书通知。

推荐:

  • 路径一定加引号;
  • 最好使用正斜杠路径;
  • 脚本中使用 safe_text() 处理怪字符。

3. 如果你只想让通知最安静

只保留:

1
2
3
"hooks": {
"Stop": [...]
}

这样每轮结束发一次,不会太吵。

4. 如果你想尽可能不错过任何状态

同时保留:

  • Stop
  • Notification

并让 Notification 匹配:

  • idle_prompt
  • permission_prompt
  • elicitation_dialog
  • auth_success
flowchart TD
  A[Claude Code 是否触发 hook] --> B[settings.json 是否被正确读取]
  B --> C[命令路径是否正确]
  C --> D[Python 脚本是否正常执行]
  D --> E[Webhook 和 Secret 是否正确]
  E --> F[网络或代理是否稳定]
  F --> G[飞书是否成功收到消息]

八、总结

这次把 Claude Code 的通知体系完整打通之后,我觉得最关键的几点经验可以概括为:

  1. StopNotification 完全不是一回事。
    前者更适合“回复结束”,后者更适合“需要你注意”。

  2. 远程 Linux 最大的坑通常是代理继承。
    如果服务器开了 Clash 或系统代理,Python 的 urllib 很可能会自动走代理,导致 webhook 偶发失败。

  3. Windows 本地最大的问题通常是路径和编码。
    路径要加引号,最好使用正斜杠;文本要经过安全转换,避免 surrogate 字符导致整个 hook 报错。

  4. 先单独打通“脚本 → 飞书”,再接入 Claude Code。
    这是整个排查流程里最节省时间的一步。

如果你的需求和我一样:

  • 远程服务器跑 Claude Code;
  • 本地终端或 VS Code 跑 Claude Code;
  • 不想一直盯着终端;
  • 希望任务完成后立即收到提醒;

那么这套方案基本已经足够稳定,而且维护成本也不高。

至此,Claude Code 在我的日常使用中,终于从“需要一直看着它”的工具,变成了一个真正可以挂着跑、等消息回来再接手的工作伙伴。