前言
最近这段时间用 Claude Code 比较多,慢慢发现一个很实际的问题:AI 写代码的时候人不应该一直盯着它。
比如我让它跑一个重构任务,然后切到浏览器查资料、看文档,或者去另一个项目里继续处理事情。几分钟后切回来,发现它早就停在权限确认那里了;再或者任务已经结束,但我完全不知道。项目窗口一多,这种反复切换就更明显了。
所以我写了一个小工具:Claude Code Notifier Plus。它的目标很简单:当 Claude Code 需要我注意时,用系统通知提醒我,并且点击通知能回到对应的 VS Code 窗口或终端窗口。

Github: https://github.com/zekunyan/claude-code-notifier-plus
为什么还要再做一个通知插件
Claude Code 本身有 Hook 机制,理论上我们可以监听通知事件,然后调用系统通知工具即可。最开始我也是这么想的。
但实际用下来有个坑:在 VS Code 扩展模式下,依赖 Notification hook 的方式并不可靠。Claude Code 的 VS Code 扩展会在自己的 webview 内部处理一部分交互,很多 CLI 模式下可用的通知逻辑,到了 VS Code 里就会悄悄失效。
所以这个插件没有继续依赖单一的 Notification hook,而是直接注册更具体的事件:
| 场景 | Claude Code Hook |
|---|---|
| 请求工具权限 | PermissionRequest |
| 需要用户确认/回答 | Elicitation |
| 主任务结束 | Stop |
| Subagent 结束 | SubagentStop |
这样 CLI 和 VS Code 扩展模式都能覆盖到。通知的触发条件也更清楚:不是“任何通知都弹一下”,而是围绕我真正需要回去处理的几个节点。
功能点
当前版本是 1.5.0,主要能力如下:
- 双模式支持:终端里的 Claude Code、VS Code 扩展里的 Claude Code 都能触发。
- 纯 CLI fallback:即使 VS Code 没有启动,也可以直接发系统通知。
- 点击跳转:macOS 上配合
terminal-notifier,点击通知可以跳回对应 VS Code 项目窗口,或者 Terminal / iTerm2 / Warp 等终端窗口。 - 任务上下文:通知里会带项目名和任务摘要,不用靠猜是哪一个窗口。
- 多实例安全:多个 VS Code 窗口同时运行时,通过 marker、lock、去重逻辑避免重复弹。
- 焦点感知和延迟通知:VS Code 已经在前台时可以不弹,也可以设置延迟,让 VS Code 内部 popup 有机会先被手动取消。
- 选中代码发送到 Claude Code:选中代码后可以通过右键、快捷键、编辑器按钮,把
@file#line引用插入 Claude Code 输入框。
快速安装
VS Code 扩展商店搜索 Claude Code Notifier Plus,或者命令行安装:
code --install-extension tutuge.claude-code-notifier-plus
如果在 macOS 上希望支持点击通知后跳转到准确窗口,推荐安装:
brew install terminal-notifier
还需要给 terminal-notifier 一次辅助功能权限:
系统设置 -> 隐私与安全性 -> 辅助功能 -> 开启 terminal-notifier
这个权限的作用不是读取内容,而是允许它把对应窗口 raise 到前台。没有这个权限时,点击通知通常只能激活 App,不能精确切到某个项目窗口。
整体架构
先看整体流程:

实现上拆成几块:
extension.js:VS Code 扩展入口,负责安装 hook、监听 pipe 文件、读取配置、发通知。hooks/notify.js:真正被 Claude Code hook 调用的脚本,负责把不同事件整理成统一 payload。lib/hook-installer.js:自动修改~/.claude/settings.json,注册事件 hook。lib/system-notification.js:封装 macOS / Windows / Linux 系统通知,以及 macOS 上的点击跳转。lib/payload.js:解析 payload、过滤事件类型。
简单来说,Claude Code 事件先进入 notify.js,再写入一个固定文件;VS Code 扩展监听这个文件,最后根据配置决定要不要发系统通知。
这个设计看起来多绕了一层,但它解决了一个实际问题:hook 脚本和 VS Code 扩展不在同一个进程里。hook 是 Claude Code 启动的 Node 进程,扩展运行在 VS Code extension host 里,两边需要一个足够简单、跨环境稳定的通信方式。
自动注册 Hook
插件启动时会把 hook 写到 ~/.claude/settings.json。核心逻辑大概是这样:
const VSCODE_HOOK_EVENTS = [
'PermissionRequest',
'Elicitation',
'Stop',
'SubagentStop',
];
function buildHookEntry(notifyScriptPath, options) {
return {
type: 'command',
command: `node "${notifyScriptPath}" # claude-code-notifier-plus`,
...options,
};
}
这里有两个细节:
- hook entry 里带了一个 sentinel:
# claude-code-notifier-plus。 - 每次安装前会先清理旧的 managed hook,再写入新的 hook。
为什么要这么做?因为用户自己的 settings.json 里可能已经有别的 hook,插件不能粗暴覆盖整个 hooks 配置。sentinel 的作用就是只识别自己管理的那一段:
function isManaged(hookEntry) {
return Array.isArray(hookEntry.hooks) &&
hookEntry.hooks.some((h) =>
typeof h.command === 'string' && h.command.includes(SENTINEL)
);
}
这样后续升级、路径变化、卸载时,都可以只处理插件自己的 hook,尽量不影响用户已有配置。这个地方是写工具类扩展时很容易忽略的:自动化配置要可回收、可重复执行、不能污染用户配置。
Payload 归一
Claude Code 传给 hook 的数据不应该直接透传到通知层。不同事件的数据结构不一样,通知层也不需要知道所有细节,所以 notify.js 会先做一次归一:
const EVENT_MAP = {
PermissionRequest: (data) => ({
event: 'permission_prompt',
text: `🔐 Requesting permission: ${data.tool_name || 'a tool'}`,
}),
Elicitation: () => ({
event: 'elicitation_dialog',
text: '❓ Claude has a question for you',
}),
Stop: () => ({
event: 'idle_prompt',
text: '✅ Task completed',
}),
SubagentStop: () => ({
event: 'subagent_stop',
text: '🤖 Subagent finished',
}),
};
最终写给扩展侧的是统一格式:
{
event,
text,
project,
taskTitle,
cwd,
termBundleId,
ts: Date.now()
}
其中 project 来自 cwd 的目录名,taskTitle 会从 transcript 里取第一条用户消息并截断。这样通知里就不是只有一句“任务完成”,而是能看到类似:
📂 MyBlog · 💬 写一篇 Claude Code Notifier Plus 的文章
✅ 任务完成
项目窗口一多,这个上下文非常重要。否则通知弹出来以后,还要自己回忆“这是哪个任务”,体验就差很多。
Pipe、Marker 和 Lock
扩展和 hook 之间用了三个文件:
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
const NOTIFY_FILE = path.join(CLAUDE_DIR, 'notify-plus.pipe');
const MARKER_FILE = path.join(CLAUDE_DIR, 'notify-plus.active');
notify-plus.pipe 不是严格意义上的 Unix pipe,而是一个固定路径的普通文件。hook 把 JSON payload 写进去,扩展用 fs.watch 监听变化:
fileWatcher = fs.watch(NOTIFY_FILE, () => {
handleNotification();
});
如果 fs.watch 不可用,再 fallback 到 fs.watchFile:
fs.watchFile(NOTIFY_FILE, { interval: 500 }, (curr, prev) => {
if (curr.mtimeMs > prev.mtimeMs) handleNotification();
});
notify-plus.active 用来记录当前活着的 VS Code extension host PID。hook 运行时会检查这个文件,如果发现扩展没有启动,就直接走 CLI fallback 发通知:
function isExtensionActive() {
const pids = content.split('\n').filter(Boolean).map(Number);
return pids.some(pid => {
try {
process.kill(pid, 0);
return true;
} catch (_) {
return false;
}
});
}
还有一个 lock 文件:notify-plus.pipe.lock。多个 VS Code 窗口都可能监听同一个 pipe,如果没有 lock,同一条 payload 可能被多个窗口同时读到并弹多次。所以扩展处理通知前会先抢锁:
function acquireLock() {
const lockFile = NOTIFY_FILE + '.lock';
const fd = fs.openSync(lockFile, 'wx');
fs.closeSync(fd);
return true;
}
这里用的是 wx,也就是文件不存在时才创建成功。这个方式很朴素,但足够处理“多个扩展进程同时抢同一条通知”的问题。
另外扩展里还有一个 2 秒窗口的去重:
function isDuplicate(event, text) {
const key = `${event}:${text}`;
const now = Date.now();
if (key === lastNotifKey && now - lastNotifTime < 2000) return true;
lastNotifKey = key;
lastNotifTime = now;
return false;
}
Lock 解决跨窗口竞争,dedup 解决短时间内同一事件重复触发。两个点关注的问题不一样,都需要有。
CLI 和 VS Code 的区分
点击通知要跳回哪里?这个问题比“发一条通知”复杂。
如果 Claude Code 跑在 VS Code 扩展里,点击通知应该回到 VS Code 项目窗口。如果 Claude Code 跑在 iTerm2 或 Warp 里,点击通知应该回到对应终端。最麻烦的是:VS Code 里也可能有 terminal,进程树判断很容易误判。
所以 hook 里优先检查 VSCODE_PID:
const isVSCode = !!process.env.VSCODE_PID ||
process.env.TERM_PROGRAM === 'vscode';
const detectedTermBundleId = isVSCode
? ''
: (getTerminalBundleId() || '');
如果是 VS Code 环境,就不再尝试检测终端,避免把 VS Code 内部 terminal 误认为外部终端。只有纯 CLI 场景,才去看 TERM_PROGRAM、LC_TERMINAL,再不行就沿进程树往上找:
if (/^Terminal$/.test(comm)) return 'com.apple.Terminal';
if (/iTerm/i.test(comm)) return 'com.googlecode.iterm2';
if (/[Ww]arp/.test(comm)) return 'dev.warp.Warp-Stable';
这个判断并不是追求覆盖所有终端,而是优先保证主流场景不要跳错。工具类插件里,“不支持某些边缘终端”通常比“跳到错误窗口”更容易接受。
macOS 点击跳转
macOS 普通 osascript display notification 能发通知,但点击行为不好控制。所以增强模式下使用 terminal-notifier。
发通知时会带上 -activate 和 -execute:
const args = [
'-title', '✨ Claude Code',
'-message', safe,
'-activate', activateBundleId,
'-group', groupId,
];
args.push('-execute', `${executeScript}; ${removeCmd}`);
-activate 负责激活 App,-execute 负责进一步执行 AppleScript,把具体窗口 raise 到前台。VS Code 场景下会按窗口标题匹配项目名:
set wins to every window whose title contains "MyBlog"
if (count of wins) > 0 then
perform action "AXRaise" of item 1 of wins
end if
这个实现依赖辅助功能权限,所以插件第一次启动时会提示用户授权。没有授权也不是完全不可用,只是从“跳到具体项目窗口”降级成“激活 VS Code / 终端 App”。
另外通知里带了 groupId,点击后会执行:
terminal-notifier -remove 'claude-notif-...'
这样点完通知以后,通知中心里不会一直堆着旧消息。这个细节对高频使用来说挺重要,不然一天跑下来通知中心会很乱。
选中代码发送到 Claude Code
除了通知之外,插件还加了一个小功能:把选中的代码快速发给 Claude Code。
入口有几个:
- 编辑器右上角按钮。
- 右键菜单。
- Lightbulb code action。
- 快捷键
Cmd+Shift+I/Ctrl+Shift+I。 - 资源管理器里右键某个文件。
实现上并没有重新发明一套与 Claude Code webview 通信的协议,而是复用 Claude Code 扩展已有的命令:
await vscode.commands.executeCommand('claude-vscode.insertAtMention');
如果 Claude Code 面板还没打开,就先打开最近的 Claude Code,再插入:
try {
await vscode.commands.executeCommand('claude-vscode.insertAtMention');
} catch {
await vscode.commands.executeCommand('claude-vscode.editor.openLast');
await vscode.commands.executeCommand('claude-vscode.insertAtMention');
}
这个功能看起来和通知没什么关系,但实际工作流里很顺手:选中一段代码,按快捷键,Claude Code 输入框里就会出现 @file#line-range 引用。它拿到的是文件引用,而不是单纯复制文本,所以后续还能基于完整上下文继续读文件。
配置项
目前配置都放在 claudeCodeNotifierPlus.* 下:
{
"claudeCodeNotifierPlus.notifyOnPermissionRequest": true,
"claudeCodeNotifierPlus.notifyOnQuestion": true,
"claudeCodeNotifierPlus.notifyOnTaskComplete": true,
"claudeCodeNotifierPlus.notifyOnSubagentStop": false,
"claudeCodeNotifierPlus.systemNotification": true,
"claudeCodeNotifierPlus.sound": false,
"claudeCodeNotifierPlus.notificationDelay": 0,
"claudeCodeNotifierPlus.showVSCodePopup": false,
"claudeCodeNotifierPlus.clickToFocus": "window",
"claudeCodeNotifierPlus.suppressWhenFocused": false
}
这里默认不开 subagent_stop,是因为 subagent 在复杂任务里可能比较频繁,如果每个子任务完成都弹一下,会比较吵。默认只提醒主流程真正需要注意的事件。
notificationDelay 这个配置也比较实用。比如你同时打开了 VS Code popup 和系统通知,可以设置几秒延迟;如果你在这几秒里手动点掉 VS Code popup,就取消系统通知,避免重复打扰。
总结
Claude Code Notifier Plus 本质上不是一个复杂项目,它解决的是一个很具体的效率问题:让 Claude Code 在需要人介入时把我叫回来,而不是让我一直盯着它。
这次实现里我自己比较关注几个点:
- 不依赖单一
Notificationhook,而是监听更明确的事件。 - hook 和 VS Code extension host 之间用固定文件通信,简单、可调试、跨环境稳定。
- CLI 和 VS Code 场景分开处理,避免点击通知跳错窗口。
- 自动修改用户配置时,用 sentinel 标记自己的内容,做到可重复安装和可清理。
- 多窗口场景下用 marker、lock、dedup 处理重复通知。
总的来说,AI Coding 工具越来越强以后,工程师的工作方式也会跟着变化。以前我们关注的是“怎么让工具更聪明”,现在也要关注“怎么让工具在合适的时候打断我”。打断太少会错过确认,打断太多又会变成噪音。这个插件就是在这个小问题上的一次实践。