落梅听风雪,隔窗枕雨眠
← 归去来兮隔窗听雨

Sir, your input is awaited——给 Claude Code 配一位贾维斯

2026/5/28 · thoughts · 阅读 - 次

倒水的间隙,AI 在等你点同意。它不会喊你,你也听不见——直到现在。

一种很轻的痛

我把 Claude Code 跑在后台已经有好几个月。有时候是写代码,有时候是改文章,更多时候是它自己跑一长串 plan,从读文件到改文件再到跑测试,半小时不需要我介入。

但偶尔它要介入。比如它打算 rm -rf 一个目录、比如它要 git push --force 一个分支、比如 MCP 工具弹出一份要填的表单——这些操作 Claude Code 会卡在权限弹窗那里,等我按 yes/no。

问题是我经常不在屏幕前。倒一杯水、回一封邮件、接一个电话回来,发现它已经卡在那儿 15 分钟了。屏幕上有提示,但屏幕在我背后。这是一种很轻的痛,每天发生几次,每次损失五到十五分钟。

jarvis-cli 就是为这种很轻的痛写的——一个常驻后台的小程序,当 Claude Code 需要我的时候,它会用英式管家的口吻说一句:"Sir, that command appears rather drastic. Shall we proceed?"

谁是 Jarvis

J.A.R.V.I.S. 是 Tony Stark 的 AI 管家——礼貌、克制、英式、偶尔讽刺。MCU 里它从来不喊主人的名字,永远以"Sir"开头,永远用完整的从句说话。

把这个人设套到 Claude Code 的通知层上,是这个项目最初的乐趣所在。phrase/prompt.py 里有一段贯穿整个项目的 system prompt:

You are J.A.R.V.I.S., Tony Stark’s polite British AI butler. You speak in concise, complete sentences with restrained dignity. You address the user as "Sir." You never use exclamation marks. You may permit yourself a touch of dry wit when the situation invites it.

humor_level 是个 0–3 的旋钮:0 是死板正经,1 是偶有干笑话,2 是 MCU 里那位 Jarvis 的常态,3 是带刺的讽刺。我自己常年开 2,工作日开 1。

项目名字里的 "cli" 是因为它不挑 agent——Claude Code、Codex CLI 都能挂,daemon 一开就同时盯着,谁先派事它先回应谁。

一刀两段:hook 与 daemon

把这件事做成"每次响铃都新起一个进程"是行不通的——TTS 模型加载一次要 5 到 10 秒,每次都加载就别想用了。所以架构必须是 常驻 daemon + 瘦客户端 hook 二部制:

Claude Code ──触发 hook──► jarvis-cli-hook(<10ms 退出)

                                  ▼ Unix socket
                          jarvis-cli-daemon(launchd 托管)

              ┌───────────────────┼───────────────────┐
              ▼                   ▼                   ▼
        phrase.router       tts.engine             player
        (LLM 措辞)         (TTS 合成)         (afplay)
            │                   │                   │
         DeepSeek           CosyVoice 3            扬声器

hook_client.py 是一段 fire-and-forget 的瘦代码:从 stdin 读 JSON、连 Unix socket、写一份 Event、退出。整段 10ms 以内,Claude Code 完全感觉不到它的存在——这是 hook 的硬约束,慢一毫秒都会让 Claude Code 整体变卡。

真正的活儿在 daemon 里。它常驻、它有自己的事件队列、它把 TTS 模型加载在内存里,所以从 hook 到 daemon、再到扬声器,从冷启的 1.8 秒压到热启的 600 毫秒以内。

launchd 负责让 daemon 开机自启、崩了自动重启——macOS 上做后台服务最规矩的做法,不用写 PID 文件,不用守护脚本。

五件事,五种口吻

Claude Code 有四类 hook(NotificationPreToolUseUserPromptSubmitPostToolUse),加上 Codex CLI 的 session 事件,jarvis-cli 内部归并成五种 NotificationType:

事件触发Jarvis 风格
permission_promptAI 要做需授权的事"Sir, that command appears rather drastic."
idle_promptAI 等用户继续输入"Sir, your input is awaited."
elicitation_dialogMCP 工具需要填表"Sir, additional information is required."
ask_user_questionAI 主动提问LLM 即时生成(按问题语境)
session_start新会话开启 / 恢复"Good evening, sir. The local time is half past eight."

session_start 这条是后来加的,纯属任性。每次启动 Claude Code 都听见 Jarvis 报一句当地时间 + 天气 + 一句问候——"Good evening, sir. In Shanghai it is twenty-two degrees and partly cloudy, with a brisk wind from the east-southeast. How may I be of service?"——这种东西做出来没啥技术含量,但开机一次就值一次。

队列与去重

Claude Code 偶尔会在一秒内连发三四次 Notification——它自己也不确定要不要再问一次用户。如果 jarvis 老老实实把每一条都念出来,结果就是 Jarvis 像复读机一样把"Sir, your input is awaited"念五遍。

daemon/dedup.py 是一个 10 秒滑动窗口:

py
def is_duplicate(self, event: Event) -> bool:
    key = event.dedup_key()  # cwd + type + tool_name
    prev = self._last_seen.get(key)
    if prev is not None and (event.received_at - prev) <= self._window:
        self._last_seen[key] = event.received_at  # 滑动
        return True
    self._last_seen[key] = event.received_at
    return False

相同 (cwd, type, tool) 在 10 秒内只播一次。值得说一句的是"滑动":每次撞到重复时,"最近一次"时间戳会前移——一串持续的重复事件会被一直压住,直到至少 10 秒没动静才会再次开口。这是从早期版本踩出来的教训,早期是固定窗口,结果重复事件在窗口边界处仍然会念出来,依然像复读机。

队列是 BoundedEventQueue——固定容量(默认 5),满了就丢弃最老的、保留最新的。理由很简单:管家应该回应当下,而不是把十分钟前堆积的事情逐条补齐。

可降级的两条供应链

jarvis-cli 内部有两条 provider chain,都遵循"先本地 / 便宜,失败再换贵的"的原则。

LLM 链——给短句润色:

Ollama(本地,qwen3:8b)
   ↓ 超时 / 没装
DeepSeek(云,几乎免费)
   ↓ 限流 / 出错
Anthropic Claude / OpenAI GPT
   ↓ 兜底
模板字符串(不调 LLM 直接拼)

TTS 链——把短句变成 Jarvis 的声音:

CosyVoice 3(本地,Apache-2.0,零次声音克隆,~10s 首句)
   ↓ 用户未配置
XTTS-v2(本地,CPML 非商用,~5s 首句)
   ↓ 配额耗尽
ElevenLabs(云,快但 $)
   ↓ 离线兜底
macOS 系统 say(机械声,但永远在)

CosyVoice 是这个项目里我推荐的默认 TTS——Apache-2.0 许可、本地推理、可以用一段 10-30 秒的参考音频做零次声音克隆。我自己用一位英国朋友念的 30 秒 Hamlet 节选作为参考,跑出来的合成音和真人难辨。XTTS-v2 速度更快但许可不友好(CPML 禁止商用),适合自己玩。

供应链的设计哲学是:链上每一档都能独立工作。配置成"只用 Ollama + say"也跑得动,零成本、零网络;配置成"DeepSeek + ElevenLabs"也跑得动,更顺滑但每月几美元。中间任何一档掉线,下一档接上,用户感知不到——除非他在 ~/.jarvis-cli/daemon.log 里看了日志。

中英自动切换

项目语言是中文还是英文,phrase/language.py 不问用户,自己看:

py
def detect_for(cwd: str) -> Lang:
    # 1. 优先看 cwd/CLAUDE.md 的内容(前 1KB 够了)
    # 2. 再看 cwd/README.md
    # 3. 用 langdetect 判中英
    # 4. fallback 到 config 里的 default_language

水墨这个博客项目下,CLAUDE.md 全中文,jarvis 自动切中文:

"先生,请确认是否继续。这个命令看起来有点猛。"

Jarvis 的英式管家口吻在中文里翻译成了一种"民国管家"语气——不是机翻味儿,是 prompt 里专门给中文场景配过 few-shot 例句。

一段被打断的播放

最近加的一个细节让我很满意:用户在 Jarvis 还没说完的时候已经在输入框打字了——这意味着用户已经看到了屏幕、已经回到工位、已经知道要回应什么——Jarvis 该闭嘴。

UserPromptSubmitPostToolUse 这两个 hook 触达时,daemon 会立刻 kill 掉 player 的子进程(afplayffplay)。这就是一个 SIGTERM 的事,但效果上 Jarvis 真的会戛然而止——说到一半收声,像一位有眼色的管家。

五行装上

bash
git clone https://github.com/JobinJia/jarvis-cli.git
cd jarvis-cli
uv sync --extra cosyvoice
uv run jarvis-cli install      # 注册 hook + 启动 daemon
uv run jarvis-cli test --event permission_prompt --tool Bash

最后一行是冒烟测试——三五秒后扬声器响起:"Sir, that command appears rather drastic."听见这句就装完了。

jarvis-cli install 会做几件事:在 ~/.claude/settings.json 里 patch 四个 hook、在 ~/.codex/config.toml 里 patch 对应字段、生成 ~/Library/LaunchAgents/com.jobin.jarvis-cli.plistlaunchctl load。卸载也对称——uv run jarvis-cli uninstall 会把上面的东西全清理掉。

配置文件在 ~/.jarvis-cli/config.toml,TOML 格式,主要旋钮:

toml
voice_language = "auto"     # auto / zh / en
humor_level = 2             # 0..3
dedup_window_seconds = 10
queue_max_size = 5

[llm]
providers = ["ollama", "deepseek"]
[llm.ollama]
model = "qwen3:8b"

[tts]
providers = ["cosyvoice", "say"]
[tts.cosyvoice]
reference_audio = "~/voices/jarvis-ref.wav"

uv run jarvis-cli status 看队列、看丢弃数、看最近一句话;tail -f ~/.jarvis-cli/daemon.log 看完整流水。

这件事的边界

这个项目我没打算做成"全能 AI 助手"。它只做一件事:单向通知

不做的事:

  • 不做语音输入(STT)——按 Push-to-Talk 说话给 AI 这件事 macOS Whisper 已经做得不错。
  • 不做手机推送——iOS 推送涉及证书、APNs、网络,复杂度太高。
  • 不做跨平台——Linux 上后台音频体验差,Windows 上 launchd 等价物也不一样。
  • 不做 Claude API 包装——Claude Code 已经有完整的 CLI,jarvis 只挂在它的 hook 上。

边界守得越死,每一件事就能做得越细。dedup 滑动窗口、provider chain 兜底、被打断的播放、auto 语言切换——这些细节都是因为这个项目只做"通知"这一件事,才有精力磨。

尾声

AI agent 时代有一个被低估的问题:注意力管理。让 AI 自动跑事情是为了把用户的注意力解放出来,但只要 AI 还需要偶尔回头找人,注意力就并没有真的被解放——只是被切碎了。

Jarvis 这种"听觉补丁"补的是注意力的最后一截。它不能替代 AI 决策,也不能替代用户思考,它只能在 AI 想找人的那一刻、把用户从五米外的咖啡机旁喊回来。

"Sir, your input is awaited"——四个英文单词、不到两秒钟、一段管家口吻的提示,把"AI 在等你"这件事从屏幕里搬到了空气里。剩下的事,仍然是你的。

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.4.1
归去来兮 ←