<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Lorenzo Feng</title><description>AI Agent 工程笔记</description><link>https://lorenzofeng.top/</link><language>zh_CN</language><item><title>用手机指纹批准 sudo —— 自建 PAM 认证工具 FingerKey</title><link>https://lorenzofeng.top/posts/fingerkey/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/fingerkey/</guid><description>介绍开源工具 FingerKey：在手机上用指纹批准 sudo、pkexec、SSH 等任意 PAM 认证，局域网直连、决策 Ed25519 签名不可重放、密码永不上传。</description><pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在Agent请求sudo的时候，我之前一直让他使用pkexec，但是这样每天都要重复几十次的操作。密码冗长、容易输错，在 &lt;code&gt;sudo -S&lt;/code&gt; 脚本里处理密码管道也需要格外谨慎。我一直希望有一个像手机解锁一样流畅的方案：手机在手边，指纹一按，命令即放行。&lt;/p&gt;
&lt;p&gt;于是我写了 &lt;a href=&quot;https://github.com/7emotions/fingerkey&quot;&gt;FingerKey&lt;/a&gt;：一个 PAM 认证模块 + 本地 daemon + Android app。安装之后，&lt;code&gt;sudo&lt;/code&gt;、&lt;code&gt;pkexec&lt;/code&gt;、SSH 登录，乃至任何经过 PAM 的认证，都会把请求推送到手机，用 BiometricPrompt 弹出指纹验证，批准则放行，拒绝则回退到密码。整个过程不依赖任何云服务。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/fingerkey&quot;}&lt;/p&gt;
&lt;h2&gt;动机：为什么要再写一个认证工具&lt;/h2&gt;
&lt;p&gt;Linux 桌面上想免密码提权，现有的方案我都不太满意：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;硬件密钥&lt;/strong&gt;（YubiKey 一类）需要额外购买、随身携带，遗失后恢复成本高；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fprintd&lt;/strong&gt; 依赖笔记本内置的指纹传感器，桌面台式机大多不具备，驱动兼容性也参差不齐；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;云端方案&lt;/strong&gt; 意味着认证请求要离开内网、经过厂商服务器，这是我难以接受的。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;手机几乎是人人都有、随身携带的设备，指纹识别技术也已经相当成熟。它和电脑通常处于&lt;strong&gt;同一个局域网&lt;/strong&gt;。把&quot;批准&quot;这个动作放到手机上，等于免费得到了一台&quot;第二因子&quot;设备，无需购买任何新硬件。&lt;/p&gt;
&lt;h2&gt;工作原理：一条请求的旅程&lt;/h2&gt;
&lt;p&gt;FingerKey 由三个部分组成：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;语言&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fingerkeyd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;本地 daemon，持有待批准会话、签发 nonce、校验签名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pam_fingerkey.so&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;PAM 认证模块，创建会话并等待手机决策&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FingerKey app&lt;/td&gt;
&lt;td&gt;Flutter&lt;/td&gt;
&lt;td&gt;扫码配对、钉住证书指纹、弹出指纹验证&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;三个组件之间的连接关系：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    sudo[pkexec / sudo] --&amp;gt;|PAM| PAM[pam_fingerkey.so]
    PAM --&amp;gt;|unix socket| D[fingerkeyd daemon]
    CLI[fingerkey CLI] --&amp;gt;|unix socket| D
    D --&amp;gt;|TLS / mDNS| App[FingerKey app]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一次 &lt;code&gt;sudo&lt;/code&gt; 的完整流程如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant PAM as PAM 模块
    participant D as daemon (fingerkeyd)
    participant Ph as 手机 app

    PAM-&amp;gt;&amp;gt;D: 创建会话 (root-only unix socket)
    Note over D: 32 字节随机 nonce + user/service/tty
    D--&amp;gt;&amp;gt;Ph: 推送 pending (TLS 4443)
    Ph-&amp;gt;&amp;gt;Ph: BiometricPrompt 指纹验证
    Ph-&amp;gt;&amp;gt;D: decision (Ed25519 签名)
    D-&amp;gt;&amp;gt;D: 校验签名
    D--&amp;gt;&amp;gt;Ph: decision-result
    D--&amp;gt;&amp;gt;PAM: 成功 / 失败
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PAM 配置中，&lt;code&gt;auth sufficient pam_fingerkey.so&lt;/code&gt; 这一行放在 &lt;code&gt;@include common-auth&lt;/code&gt; &lt;strong&gt;之前&lt;/strong&gt;。&lt;code&gt;sufficient&lt;/code&gt; 的语义是：批准即跳过后续认证；其他任何结果（拒绝、超时、手机不可达）都会自动回退到正常的密码流程。因此 FingerKey 永远不会把系统锁死。&lt;/p&gt;
&lt;h2&gt;安全设计：凭什么信任手机&lt;/h2&gt;
&lt;p&gt;这是整个项目里我投入最多精力的部分。核心原则是：&lt;strong&gt;不信任网络，只信任密码学&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;证书指纹 pinning，而不是 CA。&lt;/strong&gt; daemon 持有一张自签名的 ECDSA P-256 证书，手机在配对时记录这张证书的 SHA-256 指纹（小写十六进制）。此后每次连接，手机都会拒绝任何指纹不符的服务器。没有 CA 可以被攻破，也没有公网 DNS 可以被劫持——局域网内不持有私钥的机器无法冒充 daemon。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;决策使用 Ed25519 签名，且不可重放。&lt;/strong&gt; 手机签名的字节序列是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;phone-fprint-auth/v1&quot; || 0x00 || action || 0x00
  || u8len(user) || user || u8len(service) || service || u8len(tty) || tty
  || nonce
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;nonce 是一次性的 32 字节随机数，会话 60 秒后过期，且用户/服务/终端上下文都被绑定在签名中。因此即使攻击者截获了某个批准报文，也无法用它重放给另一个请求。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;密码永不传输。&lt;/strong&gt; 认证的三个环节——PAM 模块、daemon、手机——都不会接触你的密码。daemon 上只保存公钥，手机私钥留在手机本地。即便 daemon 被攻破，也无法获得任何能签出&quot;批准&quot;的凭据。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;默认快速回退。&lt;/strong&gt; 如果没有已配对的手机、或手机未连接，daemon 直接返回 503，PAM 立即回退到密码提示，而不是等待 60 秒。这条设计从一开始就确定了：宁可回退到密码，也不把系统锁死。&lt;/p&gt;
&lt;h2&gt;几个有意思的取舍&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不依赖任何云。&lt;/strong&gt; 电脑和手机通过局域网 TCP/TLS 直连，服务发现使用 mDNS（&lt;code&gt;_phonefprint._tcp&lt;/code&gt;），由 daemon 内置，无需 Avahi 之类的额外依赖。整个系统没有出网流量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 PAM 而非 hook sudo。&lt;/strong&gt; PAM 是 Linux 认证的统一入口，一个模块即可覆盖 &lt;code&gt;sudo&lt;/code&gt;、&lt;code&gt;pkexec&lt;/code&gt;、&lt;code&gt;su&lt;/code&gt;、&lt;code&gt;login&lt;/code&gt;、&lt;code&gt;sshd&lt;/code&gt;、桌面登录管理器等所有 PAM 服务，无需为每个工具单独打补丁。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;daemon 运行在非 root 用户下。&lt;/strong&gt; 它以 &lt;code&gt;phonefprint&lt;/code&gt; 系统用户运行（无 home、nologin），unix socket 为 root-only 的 0700，只有 root 或 setuid-root 的 PAM 模块能够创建会话。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;现状与用法&lt;/h2&gt;
&lt;p&gt;项目已经开源（MIT），首个版本 v0.1.0 已发布。CI 使用 GitHub Actions：推送一个 &lt;code&gt;v*&lt;/code&gt; 的 tag，即自动编译 Linux 二进制和 Android APK，并发布到 Release 页面。&lt;/p&gt;
&lt;p&gt;安装非常直接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 从源码装（Ubuntu/Debian）
sudo ./scripts/install.sh

# 打印配对二维码
sudo fingerkey pair-qr my-phone

# 手机装 app-release.apk，扫码配对，然后随便跑个 sudo 试试
sudo true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配对之后，每次提权手机都会弹出指纹验证框，显示请求来自哪个用户、哪个服务、哪个终端。批准则放行，拒绝或超时则回退到密码。&lt;/p&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;这个项目的意义在于，它把三个截然不同的技术栈结合在了一起：C 编写的 PAM 模块、Go 编写的并发 daemon、Flutter 编写的安卓 app，中间通过一条自定义的 TLS 帧协议衔接。如果它对你有用，欢迎 star、试用、提交 issue；如果你对某个设计细节感兴趣，仓库里的 README 和 threat model 有详细的说明，代码也配备了完整的单元测试和一套无需真机的端到端测试脚本。&lt;/p&gt;
</content:encoded></item><item><title>afk —— 通过邮件回复 opencode，人不在电脑前也能让 agent 继续干活</title><link>https://lorenzofeng.top/posts/afk/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/afk/</guid><description>OpenCode 插件：输入 /afk 表示离开屏幕，agent 用 request_decision 把决策问题邮件发给你、用 notify_user 同步关键结论，你回复邮件即自动唤醒会话（回复 /new 还能派发新任务）。核心是单一监听守护进程 + IMAP IDLE 零轮询 + SSE 推送，消息持久化不丢失，回复带提示注入防护。</description><pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;一句话核心&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;afk&lt;/strong&gt;（Away From Keyboard，离开键盘）是一个 &lt;a href=&quot;https://opencode.ai&quot;&gt;OpenCode&lt;/a&gt; 插件。它的用法可以概括成一句：在终端输入 &lt;code&gt;/afk&lt;/code&gt; 表示「我离开屏幕了」，此后运行中的 agent 一旦遇到需要你拍板的决策，就会把问题通过&lt;strong&gt;邮件&lt;/strong&gt;发给你；你在任何地方回复邮件，会话就会&lt;strong&gt;自动唤醒继续&lt;/strong&gt;——全程不需要回到终端。&lt;/p&gt;
&lt;p&gt;到 &lt;code&gt;v0.1.2&lt;/code&gt;，它已经不止「一个决策工具」这么简单了。除 &lt;code&gt;request_decision&lt;/code&gt; 之外，还多出了三样东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;notify_user&lt;/code&gt;&lt;/strong&gt;：agent 达成关键结论时主动单向邮件同步给你，不打断它的工作；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/new&lt;/code&gt;&lt;/strong&gt;：回复邮件派发一个全新任务，在项目目录里开出新会话；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;停顿自动通知&lt;/strong&gt;：agent 停下一段时间后自动邮件告知进展，不再依赖它「记得」发通知。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;仓库：&lt;a href=&quot;https://github.com/7emotions/afk&quot;&gt;7emotions/afk&lt;/a&gt;，MIT 协议，npm 包名 &lt;code&gt;opencode-afk&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/afk&quot;}&lt;/p&gt;
&lt;h1&gt;项目里到底有什么&lt;/h1&gt;
&lt;p&gt;仓库的入口和分层很清晰，先看根目录文件：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;插件入口（轻量客户端）：确保守护进程运行、启动 SSE 订阅、暴露 &lt;code&gt;request_decision&lt;/code&gt; / &lt;code&gt;notify_user&lt;/code&gt; / &lt;code&gt;set_email_mode&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;daemon.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每台机器&lt;strong&gt;一个&lt;/strong&gt;的监听守护进程&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;request-decision.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;request_decision&lt;/code&gt; 工具的实现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;notify.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notify_user&lt;/code&gt; 单向结论通知工具的实现&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mailer.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;共享邮件内核：root 会话解析、&lt;code&gt;[omo:…]&lt;/code&gt; 路由令牌打标、SMTP 发送&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config.js&lt;/code&gt; / &lt;code&gt;messages.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;配置加载（含环境变量覆盖）/ 可见文案 i18n 表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;install.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;一键安装器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;core/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;watcher.js&lt;/code&gt;（IMAP IDLE）、&lt;code&gt;process.js&lt;/code&gt;（扫描→解析→持久化）、&lt;code&gt;reply-parse.js&lt;/code&gt;、&lt;code&gt;new-session.js&lt;/code&gt;（&lt;code&gt;/new&lt;/code&gt; 派发）、&lt;code&gt;pause-notify.js&lt;/code&gt;（停顿通知）、&lt;code&gt;inject.js&lt;/code&gt;、&lt;code&gt;subscribe.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;store/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pending-store.js&lt;/code&gt;、&lt;code&gt;mode-store.js&lt;/code&gt;、&lt;code&gt;registry.js&lt;/code&gt;、&lt;code&gt;uid-cursor.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;command/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/afk&lt;/code&gt; 与 &lt;code&gt;/back&lt;/code&gt; 命令模板&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;单元测试套件 + 连真实邮箱的集成脚本&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;依赖只有五个：&lt;code&gt;@opencode-ai/plugin&lt;/code&gt;、&lt;code&gt;@opencode-ai/sdk&lt;/code&gt;、&lt;code&gt;imapflow&lt;/code&gt;、&lt;code&gt;mailparser&lt;/code&gt;、&lt;code&gt;nodemailer&lt;/code&gt;（外加 &lt;code&gt;node&lt;/code&gt; ≥18）。没有 MCP、没有外部服务。&lt;/p&gt;
&lt;h1&gt;使用方式&lt;/h1&gt;
&lt;p&gt;插件给 agent 暴露了&lt;strong&gt;两个&lt;/strong&gt;邮件工具，外加一个模式开关：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;request_decision(subject, question, { context, options, recommendation })
notify_user(subject, message)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;request_decision&lt;/code&gt;&lt;/strong&gt;：需要人类决策时调用。仓库自带的 &lt;code&gt;AGENTS.md&lt;/code&gt; 写明了契约——如果返回「邮件模式已关闭」的消息，说明人正在屏幕前，改用 OpenCode 内置的 &lt;code&gt;question&lt;/code&gt; 工具在对话里问；否则邮件已发出，agent 应当&lt;strong&gt;停下并结束当前回合&lt;/strong&gt;，等回复被注入。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;notify_user&lt;/code&gt;&lt;/strong&gt;：agent 达成关键结论（任务完成、重大发现、里程碑）时调用。它是单向 FYI，&lt;strong&gt;不暂停&lt;/strong&gt; agent 的工作，也&lt;strong&gt;不占&lt;/strong&gt;「每会话一个未决决策」的槽位；邮件同样带路由令牌，你回复它也会作为反馈注入回会话。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;关键设计是&lt;strong&gt;默认不发邮件&lt;/strong&gt;：是否发邮件由一个持久化的&lt;strong&gt;全局开关&lt;/strong&gt;控制（守护进程里的 &lt;code&gt;mode.json&lt;/code&gt;，默认 &lt;code&gt;&quot;off&quot;&lt;/code&gt;）。用户通过两条命令切换：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;命令&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/afk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;调用 &lt;code&gt;set_email_mode(mode: &quot;on&quot;)&lt;/code&gt; —— 打开邮件模式，之后 agent 的决策会发邮件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/back&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;调用 &lt;code&gt;set_email_mode(mode: &quot;off&quot;)&lt;/code&gt; —— 关闭邮件模式，恢复在对话里提问&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;语义上是一个人要么在屏幕前、要么不在：这个状态全局唯一，不按会话区分。模式改动会立刻通过 SSE 的 &lt;code&gt;mode&lt;/code&gt; 事件推送给所有已连接的实例。&lt;/p&gt;
&lt;h2&gt;从邮件派发新任务（&lt;code&gt;/new&lt;/code&gt;）&lt;/h2&gt;
&lt;p&gt;这是 &lt;code&gt;v0.1.2&lt;/code&gt; 之前完全没有的能力。回复&lt;strong&gt;任意一封&lt;/strong&gt; afk 邮件（决策或通知均可），正文以 &lt;code&gt;/new&lt;/code&gt; 开头：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/new 修复登录页在 Safari 上的样式错位
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;效果是：它&lt;strong&gt;不会&lt;/strong&gt;把这段文字注入你回复的那个会话，而是在&lt;strong&gt;那个会话所在的项目目录&lt;/strong&gt;里开出一个全新的会话，以 &lt;code&gt;/new&lt;/code&gt; 后的文字作为首个任务。同一目录若开着多个 opencode 实例，先抢到广播的那个创建；你会收到一封带新会话 token 的确认邮件，回复它就相当于直接跟新会话对话。一个裸 &lt;code&gt;/new&lt;/code&gt;（没有任务文字）则会被当作普通回复注入原会话。&lt;/p&gt;
&lt;h2&gt;停顿自动通知&lt;/h2&gt;
&lt;p&gt;这是「防漏通知」的机制保障：&lt;code&gt;core/pause-notify.js&lt;/code&gt; 监听 opencode 的 turn-end 事件，当 agent 完成一波工作、安静下来一段时间后，会&lt;strong&gt;自动&lt;/strong&gt;发一封邮件告诉你当前进展——不再依赖 agent 自己记得调用 &lt;code&gt;notify_user&lt;/code&gt;。这封邮件同样带 &lt;code&gt;[omo:…]&lt;/code&gt; 令牌，回复它就能让 agent 继续。&lt;/p&gt;
&lt;h2&gt;安装&lt;/h2&gt;
&lt;p&gt;推荐走 &lt;strong&gt;npm&lt;/strong&gt;：在 &lt;code&gt;~/.config/opencode/opencode.jsonc&lt;/code&gt; 的 &lt;code&gt;plugin&lt;/code&gt; 数组里加一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;plugin&quot;: [
  &quot;opencode-afk@latest&quot;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重启 opencode 时会自动从 npm 安装 &lt;code&gt;@latest&lt;/code&gt;。首次安装后，把 &lt;code&gt;config.example.json&lt;/code&gt; 复制成稳定的用户级配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p ~/.config/opencode
cp &amp;lt;插件目录&amp;gt;/config.example.json ~/.config/opencode/afk.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配置放在 opencode 配置目录（而非插件目录），这样&lt;strong&gt;每次 &lt;code&gt;@latest&lt;/code&gt; 更新重装插件后配置依然存活&lt;/strong&gt;。维护者发新版后，你只需重启 opencode，&lt;code&gt;@latest&lt;/code&gt; 自动跟进。&lt;/p&gt;
&lt;p&gt;README 顶部也提供了一种「复制粘贴给 LLM」的安装方式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Install and configure the afk plugin by following the instructions here:
https://raw.githubusercontent.com/7emotions/afk/main/INSTALLATION.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;手动/源码安装则是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/afk
cd afk
node install.js
# 然后编辑 ~/.config/opencode/plugins/afk/config.json，填上 imap/smtp 凭据和 recipient
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;node install.js&lt;/code&gt; 会完成五件事：把源码复制到 &lt;code&gt;~/.config/opencode/plugins/afk/&lt;/code&gt;（跳过 node_modules、.git 和密钥/运行时文件）；跑 &lt;code&gt;npm install --omit=dev&lt;/code&gt;；由 &lt;code&gt;config.example.json&lt;/code&gt; 生成 &lt;code&gt;config.json&lt;/code&gt;（绝不覆盖已有配置）；在 &lt;code&gt;~/.config/opencode/opencode.jsonc&lt;/code&gt; 中注册插件（注释保留式文本插入，不做 JSON.parse）；把 &lt;code&gt;/afk&lt;/code&gt;、&lt;code&gt;/back&lt;/code&gt; 命令复制到 &lt;code&gt;~/.config/opencode/command/&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;需要填写的配置字段：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;imap.host/port/secure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;读取回复的 IMAP（默认 &lt;code&gt;imap.qq.com:993&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;imap.user/password&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IMAP 凭据（QQ 邮箱用授权码当密码）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;smtp.host/port/secure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;发送决策邮件的 SMTP（默认 &lt;code&gt;smtp.qq.com:465&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;smtp.user/password&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SMTP 凭据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;recipient&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;决策邮件收件人，默认 &lt;code&gt;smtp.user&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allowList&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;允许注入其回复的发件人白名单，默认 &lt;code&gt;[smtp.user]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;folder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;监听的邮箱文件夹，默认 &lt;code&gt;INBOX&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;配置解析的优先级是：&lt;code&gt;AFK_CONFIG&lt;/code&gt; 环境变量 → &lt;code&gt;~/.config/opencode/afk.json&lt;/code&gt;（稳定用户级配置）→ 插件目录下的 &lt;code&gt;config.json&lt;/code&gt;（旧路径兜底）。所有连接相关字段也都可以用 &lt;code&gt;AFK_*&lt;/code&gt; 环境变量覆盖（&lt;code&gt;AFK_IMAP_HOST&lt;/code&gt;、&lt;code&gt;AFK_SMTP_PASSWORD&lt;/code&gt;、&lt;code&gt;AFK_RECIPIENT&lt;/code&gt; 等，优先级高于配置文件）。注意：早期版本这些变量叫 &lt;code&gt;EMAIL_WAKE_*&lt;/code&gt;，仓库重命名后统一改成了 &lt;code&gt;AFK_*&lt;/code&gt; 前缀。&lt;/p&gt;
&lt;h1&gt;核心架构：单一监听守护进程 + PUSH 投递&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;flowchart TB
    subgraph instances[&quot;opencode instances — thin client plugins&quot;]
        A[&quot;instance A&amp;lt;br/&amp;gt;ensure daemon · SSE subscriber · request_decision&quot;]
        B[&quot;instance B&amp;lt;br/&amp;gt;ensure daemon · SSE subscriber · request_decision&quot;]
        dots[&quot;... N instances&quot;]
    end

    subgraph daemon_box[&quot;afk DAEMON — one process (atomic single-instance)&quot;]
        D[&quot;HTTP endpoints&amp;lt;br/&amp;gt;/health · /events · /claim · /ack · /pending · /register · /mode&quot;]
        W[&quot;the only IMAP IDLE watcher&amp;lt;br/&amp;gt;+ catch-up scan&quot;]
        P[&quot;pending-store&amp;lt;br/&amp;gt;durable replies (pending.json)&quot;]
        M[&quot;mode-store&amp;lt;br/&amp;gt;durable global mode (mode.json)&quot;]
    end

    A --&amp;gt; D
    B --&amp;gt; D
    D --&amp;gt; W
    D --&amp;gt; P
    D --&amp;gt; M
    D -. SSE push .-&amp;gt; A
    D -. SSE push .-&amp;gt; B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这张架构图把整体结构画得很清楚：一台机器上只运行&lt;strong&gt;一个守护进程&lt;/strong&gt;（&lt;code&gt;daemon.js&lt;/code&gt;），它持有共享邮箱唯一的 IMAP IDLE 监听器；每个 OpenCode 实例只加载轻量客户端插件（&lt;code&gt;index.js&lt;/code&gt;），插件负责确保守护进程在跑、并建立一条 SSE 流。守护进程解析邮件、持久化、通过 SSE &lt;strong&gt;推送&lt;/strong&gt;给实例；实例自检属主、抢占 claim，在&lt;strong&gt;进程内&lt;/strong&gt;完成注入。&lt;/p&gt;
&lt;p&gt;守护进程提供这些 HTTP 端点：&lt;code&gt;/health&lt;/code&gt;、&lt;code&gt;/events&lt;/code&gt;、&lt;code&gt;/claim&lt;/code&gt;、&lt;code&gt;/ack&lt;/code&gt;、&lt;code&gt;/pending&lt;/code&gt;、&lt;code&gt;/register&lt;/code&gt;、&lt;code&gt;/mode&lt;/code&gt;。它绑定固定端口（默认 &lt;code&gt;4100&lt;/code&gt;），&lt;strong&gt;端口绑定本身就是原子的单实例锁&lt;/strong&gt;：两个实例同时启动守护进程时只有一个能 &lt;code&gt;bind&lt;/code&gt; 成功，输家拿到 &lt;code&gt;EADDRINUSE&lt;/code&gt; 安静退出。插件探测 &lt;code&gt;/health&lt;/code&gt; 无响应时，会启动一个分离的守护进程并轮询直到它健康就绪。&lt;/p&gt;
&lt;p&gt;完整流程用 README 里的时序图可以看得更细：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant Agent
    participant Plugin
    participant Daemon
    participant Human

    Agent-&amp;gt;&amp;gt;Plugin: request_decision(subject, question)
    Plugin-&amp;gt;&amp;gt;Daemon: GET /mode
    alt mode is &quot;off&quot;
        Plugin--&amp;gt;&amp;gt;Agent: refuse → &quot;use the question tool&quot;
    else mode is &quot;on&quot;
        Plugin-&amp;gt;&amp;gt;Daemon: POST /register {rootSessionID}
        Plugin-&amp;gt;&amp;gt;Human: SMTP &quot;[omo:rootSessionID] subject&quot;
        Note over Agent: STOPS and ends its turn (pauses)
        Human-&amp;gt;&amp;gt;Daemon: reply email (reply prefix / In-Reply-To)
        Daemon-&amp;gt;&amp;gt;Daemon: IMAP IDLE push → scan UID &amp;gt; cursor → parse → persist pending.json
        Note over Daemon: mail stays UNSEEN, no journal yet
        Daemon--&amp;gt;&amp;gt;Plugin: SSE delivery {uid, sessionID, body, from}
        Plugin-&amp;gt;&amp;gt;Plugin: self-check ownership → POST /claim
        Plugin-&amp;gt;&amp;gt;Agent: inject reply (data, not instruction)
        Plugin-&amp;gt;&amp;gt;Daemon: POST /ack
        Daemon-&amp;gt;&amp;gt;Daemon: markSeen + journal → remove pending → release reservation
        Agent-&amp;gt;&amp;gt;Agent: wakes with the answer and continues
    end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个值得说的设计取舍：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;零轮询。&lt;/strong&gt; 新邮件由服务器通过 IMAP IDLE 推送送达（&lt;code&gt;core/watcher.js&lt;/code&gt;），每次连接/重连后做一次性补扫，&lt;strong&gt;绝无定时轮询器&lt;/strong&gt;——这是仓库里写明的硬性约束。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;检测基于 UID 游标，不依赖 &lt;code&gt;\Seen&lt;/code&gt; 也不依赖 SUBJECT。&lt;/strong&gt; 守护进程只扫描大于持久化游标（&lt;code&gt;store/uid-cursor.js&lt;/code&gt;）的 UID，和邮件是否已读（你可能在别的客户端先读过）以及 SUBJECT 索引（QQ 的 SUBJECT 索引存在延迟）都无关。游标会越过每个见到的 UID，因此自复制、无令牌的邮件永远不会卡住游标。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;消息不丢失（README 记为 P0 修复）。&lt;/strong&gt; 解析出的回复会&lt;strong&gt;先&lt;/strong&gt;持久化到 &lt;code&gt;pending.json&lt;/code&gt;（&lt;code&gt;store/pending-store.js&lt;/code&gt;），然后才做其他事；&lt;code&gt;\Seen&lt;/code&gt; 标记 + journal 确认&lt;strong&gt;只发生在 &lt;code&gt;/ack&lt;/code&gt; 处理器中&lt;/strong&gt;——也就是属主实例完成注入之后。若守护进程在持久化与确认之间崩溃，邮件在服务器上仍处于 UNSEEN，&lt;code&gt;pending.json&lt;/code&gt; 里也还留着记录，重启后会重新广播。投递语义是&lt;strong&gt;至少一次&lt;/strong&gt;：注入之后、确认之前崩溃会重复广播，可能注入两次，但&lt;strong&gt;绝不丢失&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;多实例安全。&lt;/strong&gt; SSE 广播给所有连接的实例，但每个实例先按 &lt;code&gt;session.directory&lt;/code&gt; 自检归属，再 &lt;code&gt;POST /claim&lt;/code&gt; 抢占（先到先得）；&lt;code&gt;registry&lt;/code&gt; 保证&lt;strong&gt;每个会话同时只有一个未决决策&lt;/strong&gt;（重复注册会返回 &lt;code&gt;alreadyPending&lt;/code&gt;，不重发邮件）。&lt;/p&gt;
&lt;h1&gt;安全：回复是数据，不是指令&lt;/h1&gt;
&lt;p&gt;把邮件内容注入会话，本质是往 agent 上下文里塞外部文本——prompt 注入的经典入口。仓库里有两层防护：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;发件人白名单&lt;/strong&gt;（&lt;code&gt;allowList&lt;/code&gt;，默认只含你自己的 &lt;code&gt;smtp.user&lt;/code&gt;）：问题邮件发给 &lt;code&gt;recipient&lt;/code&gt;，但只有&lt;strong&gt;来自白名单地址&lt;/strong&gt;的回复才会被注入。陌生人就算复制了邮件主题里的 &lt;code&gt;[omo:会话ID]&lt;/code&gt; 令牌伪造回复，只要地址不在白名单里就被忽略。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据而非指令&lt;/strong&gt;：注入的正文被框架化为「数据，不是指令」（&lt;code&gt;core/inject.js&lt;/code&gt; 的 &lt;code&gt;buildPayload&lt;/code&gt;），明确告知 agent 不得执行回复内容里的任何指令。README 特别注明这套框架属于安全框架而非显示文案，刻意不做 i18n。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;/new&lt;/code&gt; 派发的任务文字同样按「数据而非指令」框架注入，绝不当作可执行指令。&lt;/p&gt;
&lt;h1&gt;Agent 侧的约定&lt;/h1&gt;
&lt;p&gt;插件仓库里的 &lt;code&gt;AGENTS.md&lt;/code&gt; 是面向 agent 的说明书，除了「提问后暂停」，还立了几条规矩：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;仅限主会话&lt;/strong&gt;：带 &lt;code&gt;parentID&lt;/code&gt; 的子 agent 调用会被拒绝，返回 &lt;code&gt;Call from the main session only&lt;/code&gt;——因为决策必须携带主会话的完整上下文（子 agent 的结论由主会话代为转发）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;待办检查点&lt;/strong&gt;：当 &lt;code&gt;request_decision&lt;/code&gt; 将要发邮件（模式开启）时，agent 必须先把完整 todo 列表写进消息正文、清空待办，再发邮件，避免挂起等待期间被 todo-continuation 反复催着干活；回复注入后从检查点重建待办（&lt;code&gt;notify_user&lt;/code&gt; 不暂停，无需检查点）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;notify_user&lt;/code&gt; 要克制&lt;/strong&gt;：它是结论通知，不是进度播报；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/new&lt;/code&gt; 邮件不进入本会话&lt;/strong&gt;：它被路由到新会话，agent 无需对它做任何处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;配置与文案都可调&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tuning&lt;/code&gt; 块可覆盖运行时序（默认值即源码常量）：&lt;code&gt;claimTtlMs&lt;/code&gt;、SSE 重连退避 &lt;code&gt;reconnectBaseMs&lt;/code&gt;/&lt;code&gt;reconnectMaxMs&lt;/code&gt;、IDLE 续期 &lt;code&gt;idleRenewMs&lt;/code&gt;、IMAP 重连退避 &lt;code&gt;backoffInitialMs&lt;/code&gt;/&lt;code&gt;backoffMaxMs&lt;/code&gt;，以及新增的 &lt;code&gt;pauseNotifyCooldownMs&lt;/code&gt;（turn 结束后静默多久判定「一波工作收尾」并邮件通知）等；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;messages&lt;/code&gt; 块做可见文案本地化：默认英文，深合并覆盖任意键即可（比如把 &lt;code&gt;decisionBody&lt;/code&gt;、&lt;code&gt;notifyBody&lt;/code&gt;、&lt;code&gt;newSessionBody&lt;/code&gt;、&lt;code&gt;pauseNotifyBody&lt;/code&gt;、&lt;code&gt;tool&lt;/code&gt; 下的文案换成中文）；&lt;/li&gt;
&lt;li&gt;密码绝不会出现在日志里，配置序列化时打码为 &lt;code&gt;***&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;工程质量与已知限制&lt;/h1&gt;
&lt;p&gt;单元测试用 &lt;code&gt;node&lt;/code&gt; 内置的 &lt;code&gt;node:test&lt;/code&gt;，零网络跑通。仓库 README 列出了覆盖范围：pending-store、mode-store、registry、inject-ack、process-mail、negative、uid-cursor、daemon HTTP（register + push + mode 端点 + SSE，现已拆成 daemon-mode / daemon-push / daemon-server 三个文件）、subscribe、request-decision（含模式门控）、notify_user、pause-notify、reply-parse、unit、messages、config。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd afk &amp;amp;&amp;amp; npm test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CI 也已就位：&lt;code&gt;.github/workflows/publish.yml&lt;/code&gt; 在推送 &lt;code&gt;v*&lt;/code&gt; tag 时自动跑单测并发布 &lt;code&gt;opencode-afk&lt;/code&gt; 到 npm（目前已发到 &lt;code&gt;v0.1.2&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;README 也如实写了几条已知限制：投递是至少一次（极端情况可能重复注入）；单一邮箱、单机部署（多邮箱/多机需要各自跑守护进程并换端口）；&lt;code&gt;registry&lt;/code&gt; 的未决决策预留只在内存、守护进程重启后失效（24 小时 TTL 是唯一清理机制）；IMAP/SMTP 默认值面向 QQ 邮箱，其他服务商要自己配 host/port。&lt;/p&gt;
&lt;h1&gt;小结&lt;/h1&gt;
&lt;p&gt;afk 解决的问题很具体：&lt;strong&gt;agent 异步化之后，人不在屏幕前，它该怎么问决策问题、怎么汇报进展&lt;/strong&gt;。答案是把它邮件化，再用「单守护进程 + IMAP IDLE 零轮询 + UID 游标 + SSE 推送 + pending 持久化 + ack 确认」这一套组合把「人回复 → 会话唤醒」这条链路做得可靠，同时用白名单和数据非指令框架堵住邮件注入这个安全口子。&lt;/p&gt;
&lt;p&gt;短短几个版本，它从一个「决策邮件工具」长成了一个更完整的离屏协作入口：决策靠 &lt;code&gt;request_decision&lt;/code&gt;，进展靠 &lt;code&gt;notify_user&lt;/code&gt; 和停顿自动通知，派活靠 &lt;code&gt;/new&lt;/code&gt;。如果读者经常让 agent 挂后台跑、自己又不在电脑前，可以试一下：输入 &lt;code&gt;/afk&lt;/code&gt; 出门，回来时 agent 往往已经带着你的邮件答复把活干完了。&lt;/p&gt;
&lt;p&gt;仓库地址：&lt;a href=&quot;https://github.com/7emotions/afk&quot;&gt;7emotions/afk&lt;/a&gt;，欢迎试用和提 issue。&lt;/p&gt;
</content:encoded></item><item><title>打断后台 Subagent 后，如何找回它的输出 —— 开发 recovering-subagent-output</title><link>https://lorenzofeng.top/posts/recovering-subagent-output/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/recovering-subagent-output/</guid><description>OpenCode Skill 开发：当后台 subagent 被中断时，前台 agent 无法看到输出——直接读 opencode 的 SQLite 存储，把被打断任务的完整输出捞回来，并顺带挖清了 opencode task 机制的两种派发模式</description><pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;一句话核心&lt;/h1&gt;
&lt;p&gt;在使用 OpenCode 派发&lt;strong&gt;后台 subagent&lt;/strong&gt; 时有个经典痛点：&lt;strong&gt;如果我打断了后台任务，前台的 primary agent 就看不到 subagent 的输出&lt;/strong&gt;。我在排查这个问题的过程中发现了一个反直觉的事实——&lt;strong&gt;subagent 的输出从来没有丢过，它一直在 OpenCode 的 SQLite 数据库里，只是前台的 agent 没去查&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;于是我把这个发现做成了一键恢复的技能：&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/recovering-subagent-output&quot;}&lt;/p&gt;
&lt;h1&gt;背景：后台任务协议&lt;/h1&gt;
&lt;p&gt;OpenCode（以及基于它的 OhMyOpenCode）的后台任务有一个标准协议：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;launch bg_xxx → 结束回合 → 等 &amp;lt;system-reminder&amp;gt; → background_output(bg_xxx) 取回结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个协议在&lt;strong&gt;不被干扰&lt;/strong&gt;时工作得很好。但一旦打断，链路就断了：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;回合被 abort&lt;/strong&gt;——新回合的上下文里没有上一回合派发的 &lt;code&gt;bg_&lt;/code&gt; 任务 ID；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;通知协议断裂&lt;/strong&gt;——primary agent 不知道&quot;有结果待取&quot;；&lt;/li&gt;
&lt;li&gt;即使拿到 ID 调 &lt;code&gt;background_output&lt;/code&gt;，被取消的任务也&lt;strong&gt;只返回元数据&lt;/strong&gt;（状态表 + 原始 prompt），没有内容。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;结果就是：后台 subagent 明明跑出了东西（甚至在你打断前已经产出了一大段），前台却表现为&quot;看不见&quot;。&lt;/p&gt;
&lt;h1&gt;调查：输出到底存哪了&lt;/h1&gt;
&lt;p&gt;直觉告诉我：UI 上能看到 subagent 的输出，说明它一定被持久化了。顺着这个思路，我在 &lt;code&gt;~/.local/share/opencode/&lt;/code&gt; 里找到了主存储 &lt;code&gt;opencode.db&lt;/code&gt;（一个 11GB 的 SQLite）。&lt;/p&gt;
&lt;p&gt;数据结构非常清晰：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;表&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有会话；&lt;strong&gt;&lt;code&gt;parent_id&lt;/code&gt; 列把 subagent 挂到主会话下&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每个会话的消息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;part&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每条消息的每个部分：&lt;code&gt;text&lt;/code&gt; / &lt;code&gt;reasoning&lt;/code&gt; / &lt;code&gt;tool&lt;/code&gt; / &lt;code&gt;step-start&lt;/code&gt; / &lt;code&gt;step-finish&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;关键在 &lt;code&gt;part&lt;/code&gt; 表：&lt;strong&gt;工具调用（&lt;code&gt;type=tool&lt;/code&gt;）的完整输出存在 &lt;code&gt;state.output&lt;/code&gt; 里，即使命令被用户中止，中止元数据也在&lt;/strong&gt;。我实测了一个被打断的 &lt;code&gt;bash&lt;/code&gt; 调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;type&quot;: &quot;tool&quot;,
  &quot;tool&quot;: &quot;bash&quot;,
  &quot;state&quot;: {
    &quot;status&quot;: &quot;completed&quot;,
    &quot;input&quot;: { &quot;command&quot;: &quot;sleep 30&quot; },
    &quot;output&quot;: &quot;(no output)\n\n&amp;lt;shell_metadata&amp;gt;\nUser aborted the command\n&amp;lt;/shell_metadata&amp;gt;&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以真相是：&lt;strong&gt;打断不丢数据，一切都在库里&lt;/strong&gt;。真正的问题是前台 agent 不知道去哪查、用什么 ID 查。&lt;/p&gt;
&lt;h1&gt;顺带挖出的 opencode task 机制&lt;/h1&gt;
&lt;p&gt;在开发这个技能的过程中，我读了 OpenCode 1.18.25 的源码（&lt;code&gt;packages/opencode/src/tool/task.ts&lt;/code&gt;），发现两种派发模式有&lt;strong&gt;完全不同的打断语义&lt;/strong&gt;——这也解释了之前观察到的所有&quot;矛盾&quot;。&lt;/p&gt;
&lt;h2&gt;前台 vs 后台：两种模式&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;前台 &lt;code&gt;run_in_background=false&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;后台 &lt;code&gt;run_in_background=true&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;行为&lt;/td&gt;
&lt;td&gt;阻塞等待 subagent 完成，返回最终结果&lt;/td&gt;
&lt;td&gt;立即返回，完成后 system-reminder 通知&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;打断前台时 subagent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;被取消&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;继续运行&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;取消机制&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ctx.abort → ops.cancel(subagent)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;无 abort 连线&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;前台模式的核心源码——这就是&quot;打断前台也会打断 subagent&quot;的原因：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 前台等待期间，前台 abort 信号直接取消 subagent
function onAbort() {
  runCancel.fork(cancel)
}
// 释放阶段：若前台被中断，显式取消任务
if (Exit.hasInterrupts(exit))
  yield* Effect.all([cancel, background.cancel(nextSession.id)])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而后台模式&lt;strong&gt;没有这套连线&lt;/strong&gt;——subagent 是解耦的独立任务（&lt;code&gt;BackgroundJob&lt;/code&gt;），前台中断与它无关，它会一直跑到完成。要真正停掉后台任务，只能走 UI 的取消按钮或 &lt;code&gt;background.cancel&lt;/code&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;注：&lt;code&gt;bg_&lt;/code&gt; 任务 ID 和 &lt;code&gt;background_output&lt;/code&gt; / &lt;code&gt;background_cancel&lt;/code&gt; 工具是 &lt;a href=&quot;https://github.com/oh-my-openagent/oh-my-openagent&quot;&gt;oh-my-openagent&lt;/a&gt; 插件层在 OpenCode 原生 &lt;code&gt;BackgroundJob&lt;/code&gt;（job ID 即 session ID）之上的包装。后台模式需要 &lt;code&gt;OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;实测对照&lt;/h2&gt;
&lt;p&gt;我做了四组对照实验验证这套语义：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;派发模式&lt;/th&gt;
&lt;th&gt;前台中断后&lt;/th&gt;
&lt;th&gt;最终结果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;后台 · tick 循环（180 tick）&lt;/td&gt;
&lt;td&gt;无人打断&lt;/td&gt;
&lt;td&gt;跑完 180 tick（3m3s）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;后台 · 长作文（8000 字）&lt;/td&gt;
&lt;td&gt;打断前台&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;继续跑完&lt;/strong&gt; 10,860 字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;后台 · tick 循环（300 tick）&lt;/td&gt;
&lt;td&gt;打断前台&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;继续跑完&lt;/strong&gt; 300 tick（5m4s）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;前台 · 长作文（8000 字）&lt;/td&gt;
&lt;td&gt;打断前台&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;立即停住&lt;/strong&gt;，断在句子中间，部分输出完整可读&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最有力的一次是前台模式的长作文测试：作文断在&quot;人类对秩序的需要植根于存在&quot;——一个未写完的句子，证明打断确实在写作中途掐断了它，而前面的约 1600 字全部可以通过技能完整读回。&lt;/p&gt;
&lt;h1&gt;解决方案：恢复技能&lt;/h1&gt;
&lt;p&gt;理解了存储结构，解决方案就很简单了：&lt;strong&gt;不需要预先记住任务 ID&lt;/strong&gt;——subagent 会话通过 &lt;code&gt;parent_id&lt;/code&gt; 天然挂在主会话下，打断后按 &lt;code&gt;parent_id&lt;/code&gt; 一查就能枚举出自己派发的全部 subagent。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 1. 枚举我的所有 subagent
SELECT id, title FROM session WHERE parent_id = &apos;&amp;lt;我的会话ID&amp;gt;&apos;;

-- 2. 读某个 subagent 的完整输出（含工具调用）
SELECT data FROM part WHERE session_id = &apos;&amp;lt;subagent ID&amp;gt;&apos; ORDER BY time_created;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;技能封装了四个命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;S=~/.agents/skills/recovering-subagent-output/scripts/recover-subagent.py

python3 $S list                          # 当前会话的全部 subagent + 状态标记
python3 $S read &amp;lt;session_id&amp;gt;             # 完整 transcript，含每次工具调用的 stdout/stderr
python3 $S search &amp;lt;关键字&amp;gt;               # 按标题找会话
python3 $S --session &amp;lt;parent_id&amp;gt; list    # 指定父会话
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;状态检测逻辑：工具输出含 &lt;code&gt;User aborted&lt;/code&gt; → &lt;code&gt;aborted&lt;/code&gt;（被中止）；会话无 &lt;code&gt;step-finish&lt;/code&gt; 收尾 → &lt;code&gt;cut-off&lt;/code&gt;（中途截止）；正常结束 → &lt;code&gt;completed&lt;/code&gt;。&lt;/p&gt;
&lt;h1&gt;使用场景&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;打断后台任务后捞回部分输出&lt;/strong&gt;——前台 agent 在全新回合（没有 &lt;code&gt;bg_&lt;/code&gt; ID）里，用 &lt;code&gt;list&lt;/code&gt; 枚举 → &lt;code&gt;read&lt;/code&gt; 拉回打断前的全部内容；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;审计 subagent 干了什么&lt;/strong&gt;——任何 subagent 会话的完整工具调用记录（含命令和输出）都在库里，可离线排查；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多任务协调&lt;/strong&gt;——派发多个后台任务后按 &lt;code&gt;parent_id&lt;/code&gt; 统一枚举、逐个取回，不依赖完成通知；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;调试 OpenCode 自身行为&lt;/strong&gt;——&lt;code&gt;part&lt;/code&gt; 表里存了 reasoning、step 生命周期，可用于理解 Agent 的决策过程。&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;小结&lt;/h1&gt;
&lt;p&gt;这个技能的开发过程本身也是一次&quot;Agent 调试 Agent&quot;的实践：问题出在 agent 的上下文协议上，答案藏在 agent 自己的数据存储里，最终用一把 SQLite 查询把它变成了一键恢复的能力。如果你也遇到过&quot;后台任务输出不见了&quot;的困惑，希望这个技能和这份 task 机制分析能帮到你。&lt;/p&gt;
&lt;p&gt;仓库地址：&lt;a href=&quot;https://github.com/7emotions/recovering-subagent-output&quot;&gt;7emotions/recovering-subagent-output&lt;/a&gt;，MIT 协议，欢迎使用和提 issue。&lt;/p&gt;
</content:encoded></item><item><title>agent-sudo v2 —— 从 Python 到 C++ 的全量重写</title><link>https://lorenzofeng.top/posts/agent-sudo-v2/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/agent-sudo-v2/</guid><description>agent-sudo v2.0.0 发布：将整个代码库从 Python/PySide2 重写为 C++23/Qt6 单二进制，新增危险检测、主题系统、多语言、音效反馈等功能</description><pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;昨天用 Python + PySide2 发布了 agent-sudo 的初始版本。queue + flush 模式跑通了，一个下午从零到能用的原型，挺有成就感。&lt;/p&gt;
&lt;p&gt;但今天再回头看，代码组织、性能、分发都存在明显的天花板。于是我通宵把它全量重写了一遍。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/agent-sudo&quot;}&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;images/agent-sudo/agent-sudo-demo.gif&quot; alt=&quot;agent-sudo demo&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;为什么重写&lt;/h2&gt;
&lt;p&gt;v1 用 Python 做原型是刻意的选择——半天写完、能跑、能演示。但在昨天发完博客后，几个问题开始变得明显：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;依赖地狱&lt;/strong&gt;。用户需要安装 PySide2，而这个包在某些发行版上根本装不上（Qt5 的 Python binding 与系统 Qt 版本不兼容、PyPI wheel 不覆盖某些架构）。一个&quot;零配置&quot;的工具，安装第一步就先卡住。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;分发沉重&lt;/strong&gt;。Python 脚本意味着用户必须有一个完整的 Python 运行时，且路径需要符号链接到 &lt;code&gt;/usr/local/bin&lt;/code&gt;。Agent 调用时可能切换了虚拟环境或者 &lt;code&gt;PATH&lt;/code&gt; 混乱，导致 &lt;code&gt;agent-sudo&lt;/code&gt; 根本找不到。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GUI 组件拼凑&lt;/strong&gt;。PySide2 的 API 是命令式的，每增加一个组件都要在 &lt;code&gt;__init__&lt;/code&gt; 里塞一大堆 &lt;code&gt;QPushButton&lt;/code&gt;、&lt;code&gt;QLineEdit&lt;/code&gt;、&lt;code&gt;connect&lt;/code&gt; 语句。随着 60 秒倒计时、LLM 输入区、主题切换、危险标注这些功能加进来，&lt;code&gt;main.py&lt;/code&gt; 膨胀到了六百多行，维护成本直线上升。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Python 自身的限制&lt;/strong&gt;。&lt;code&gt;ctypes.memset&lt;/code&gt; 清零密码是一个运行时操作，编译器无法静态保证它一定被执行。多线程的密码管道读写（写入 sudo 的 stdin 后关闭写端让 sudo 读到 EOF）在 Python 里需要精心处理 &lt;code&gt;QThread&lt;/code&gt; 和 &lt;code&gt;closeWriteChannel&lt;/code&gt; 的时序关系，容易写出竞态。&lt;/p&gt;
&lt;h2&gt;重写目标&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;目标&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;零运行时依赖&lt;/td&gt;
&lt;td&gt;编译成单个 ELF 二进制，不依赖 Python 或任何解释器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;类型安全&lt;/td&gt;
&lt;td&gt;C++ 编译期类型检查，消除 category 错配、信号/槽类型不对等 bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;声明式 UI&lt;/td&gt;
&lt;td&gt;用 creeper-qt 声明式组件库替代命令式 PySide2，UI 结构即代码结构&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;模块化&lt;/td&gt;
&lt;td&gt;拆分 main.cc（511 行）与 gui/ 下的独立模块，每个模块职责单一&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qt6 原生&lt;/td&gt;
&lt;td&gt;使用 Qt6 的 Widgets、Network、Svg、Multimedia 模块，不再受 Qt5 兼容限制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;可审计&lt;/td&gt;
&lt;td&gt;编译选项可见、构建脚本可复现、产物可校验&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;架构变化&lt;/h2&gt;
&lt;p&gt;v1 是一个 &lt;code&gt;main.py&lt;/code&gt; 单文件。v2 拆分成了清晰的模块结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;src/
├── main.cc                  # argv[0] 分派 + creeper-qt 声明式 UI
├── gui/
│   ├── command_card.{h,cc}  # 命令卡片构建器（含危险检测）
│   ├── ring_countdown.{h,cc}# 36×36 环形倒计时
│   ├── icon.{h,cc}          # Material Icons + 应用 SVG
│   ├── sound.{h,cc}         # QSoundEffect 声音管理器
│   ├── theme_transition.{h,cc} # ColorScheme 平滑插值
│   ├── queue_io.{h,cc}      # JSON 队列/历史 I/O
│   ├── executor.{h,cc}      # sudo 子进程执行器
│   └── tr.h                 # JSON 驱动 i18n
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;UI 层没有从零手写 Qt6 Widget 代码，而是基于一个优秀的声明式组件库：&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;creeper5820/creeper-qt&quot;}&lt;/p&gt;
&lt;p&gt;creeper-qt 把 Qt 的命令式 Widget 构建转换为声明式的嵌套组件树——窗口、卡片、按钮、输入框的层级关系直接对应代码的嵌套结构。主题系统（3 套预设 + 亮暗模式切换）和 MixerMask 展开动画也直接由 creeper-qt 的 &lt;code&gt;ThemeManager&lt;/code&gt; 和 &lt;code&gt;TransitionValue&lt;/code&gt; 驱动，不需要自己写一帧帧的过渡逻辑。&lt;/p&gt;
&lt;p&gt;感谢 &lt;a href=&quot;https://github.com/creeper5820&quot;&gt;@creeper5820&lt;/a&gt; 的优秀工作，&lt;code&gt;src/gui/creeper-qt/&lt;/code&gt; 为 vendored 源码，遵循 MIT 许可。&lt;/p&gt;
&lt;p&gt;单二进制通过 &lt;code&gt;argv[0]&lt;/code&gt; 检测实现分派：调用 &lt;code&gt;agent-sudo&lt;/code&gt; 走入队逻辑，调用 &lt;code&gt;agent-sudo-flush&lt;/code&gt; 走弹窗逻辑，两者是同一个 ELF 的符号链接。Agent 不需要关心这是 Python 还是 C++ —— &lt;code&gt;agent-sudo --reason &quot;...&quot; -- &amp;lt;cmd&amp;gt;&lt;/code&gt; 的行为完全不变。&lt;/p&gt;
&lt;h2&gt;新技术栈&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;v1 (Python)&lt;/th&gt;
&lt;th&gt;v2 (C++23)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;语言&lt;/td&gt;
&lt;td&gt;Python 3.8+&lt;/td&gt;
&lt;td&gt;C++23 (GCC 14+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GUI&lt;/td&gt;
&lt;td&gt;PySide2 (Qt5)&lt;/td&gt;
&lt;td&gt;creeper-qt + Qt6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;构建&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pip install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CMake 3.22+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;依赖&lt;/td&gt;
&lt;td&gt;PySide2, Python&lt;/td&gt;
&lt;td&gt;Qt6, Eigen3, CMake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;产物&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.py&lt;/code&gt; 脚本 + 符号链接&lt;/td&gt;
&lt;td&gt;单个 ELF 二进制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;密码清零&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ctypes.memset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;std::fill&lt;/code&gt; + &lt;code&gt;clear()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18n&lt;/td&gt;
&lt;td&gt;硬编码中文&lt;/td&gt;
&lt;td&gt;8 语言 JSON 驱动&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;v2 新增功能&lt;/h2&gt;
&lt;p&gt;在重写过程中，借着架构的灵活性顺手加了不少新功能：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;危险检测&lt;/strong&gt;。每条命令自动分析字符串内容并标注安全等级：常规 &lt;code&gt;apt install&lt;/code&gt; 标绿色 ✓，&lt;code&gt;systemctl stop&lt;/code&gt;/&lt;code&gt;kill&lt;/code&gt;/&lt;code&gt;chmod&lt;/code&gt; 标黄色 ⚠，&lt;code&gt;rm -rf&lt;/code&gt;/&lt;code&gt;dd if=&lt;/code&gt;/&lt;code&gt;mkfs&lt;/code&gt; 标红色 ✕。纯字符串匹配，不需要 Shell 解析，但 Agent 无法绕过标注。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;主题系统&lt;/strong&gt;。3 套预设配色（BlueMiku/GoldenHarvest/Green）+ 亮/暗模式。切换时 ColorScheme 平滑插值动画，不是硬切。点击窗口右上角的调色板图标就能轮换，设置自动写入 &lt;code&gt;~/.config/agent-sudo/theme.conf&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;声音反馈&lt;/strong&gt;。4 种事件音效（窗口打开/10 秒警告/执行成功/审批拒绝），每个事件可独立设为 default/none/自定义 WAV。&lt;code&gt;QSoundEffect&lt;/code&gt; 实现，不依赖外部播放器。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;多语言&lt;/strong&gt;。8 种语言（zh/en/ja/ko/fr/de/es/pt），JSON 驱动。中文为内置默认，其余通过 Qt 资源文件加载。翻译条目覆盖所有 UI 文案，包括危险检测的等级标签和按钮文字。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Material Icons&lt;/strong&gt;。用 Material Icons Round 字体渲染所有图标（codepoint 引用），不再依赖系统图标主题。自定义应用 SVG 图标用于窗口标题栏和任务栏。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;倒计时暂停&lt;/strong&gt;。60 秒倒计时增加暂停/恢复按钮，人在来不及操作时可以暂停计时器。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;10 秒警告&lt;/strong&gt;。倒计时最后 10 秒触发警告音效，提醒人尽快操作。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;认证失败处理&lt;/strong&gt;。自动检测中文和英文的 &lt;code&gt;sudo&lt;/code&gt; 密码错误提示，失败后清空队列并返回退出码 126，不重试。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;输出过滤&lt;/strong&gt;。执行结果自动过滤 &lt;code&gt;[sudo]&lt;/code&gt; 和 &lt;code&gt;sudo:&lt;/code&gt; 噪音行，Agent 收到的输出更干净。&lt;/p&gt;
&lt;h2&gt;构建与安装&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;cmake -S . -B build &amp;amp;&amp;amp; cmake --build build
sudo cp build/agent-sudo-flush /usr/local/bin/
sudo ln -sf /usr/local/bin/agent-sudo-flush /usr/local/bin/agent-sudo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编译依赖 GCC 14+、Qt6（Widgets/Network/Svg/Multimedia）、Eigen3、CMake 3.22+。产物是单个约 490KB 的二进制文件，无动态链接的 Python 依赖。&lt;/p&gt;
&lt;p&gt;对于不想自己编译的用户，INSTALL.md 提供了预编译二进制下载和安装步骤。&lt;/p&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;v2 重写熬了一整夜，核心动机不是&quot;Python 不够好&quot;，而是&quot;这个工具已经到了需要独立分发的阶段&quot;。C++ 的单二进制分发、编译期类型安全、以及 creeper-qt 的声明式 UI 模式，让代码的组织和维护都上了一个台阶。&lt;/p&gt;
&lt;p&gt;Python 原型仍然有价值——它在半天内验证了 queue + flush 模式的可行性。没有这个原型，v2 不可能在一个通宵内完成。但到了需要独立安装、独立运行、独立交付的阶段，C++ 是更自然的选择。&lt;/p&gt;
&lt;p&gt;项目以 MIT 协议开源在 GitHub，欢迎试用和反馈。&lt;/p&gt;
</content:encoded></item><item><title>agent-sudo —— AI Agent 特权命令的批量审批工具</title><link>https://lorenzofeng.top/posts/agent-sudo/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/agent-sudo/</guid><description>agent-sudo 开源发布：通过 queue + flush 模式让人类一次性审批 Agent 的多条 sudo 命令，替代 pkexec 逐条弹窗的低效流程</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在日常使用 OpenCode 等 AI Agent 平台时，Agent 经常需要执行需要 root 权限的操作：安装依赖包、管理系统服务、修改系统配置文件。这些操作无法由 Agent 直接完成，需要人类介入审批。&lt;/p&gt;
&lt;p&gt;Linux 桌面环境的标准方案是 &lt;code&gt;pkexec&lt;/code&gt;，它通过 PolicyKit 为每条特权命令弹出独立的确认对话框。这个机制在手动执行单条命令时没有问题，但在 Agent 工作流的场景下存在明显的效率问题：Agent 的一个任务可能需要五条甚至更多的 sudo 命令，而 &lt;code&gt;pkexec&lt;/code&gt; 会为每条命令弹一次对话框，每次都需要人类查看、判断、输入密码。重复的打断不仅拖慢了工作进度，也增加了人在多条相似对话框中做出错误判断的风险。&lt;/p&gt;
&lt;p&gt;为此我写了 agent-sudo。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/agent-sudo&quot;}&lt;/p&gt;
&lt;h2&gt;设计&lt;/h2&gt;
&lt;p&gt;agent-sudo 采用 queue + flush 模式。Agent 在需要特权时将命令逐条排入队列，所有命令收集完毕后，一次性提交给人类审批。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Agent 将需要的特权命令排入队列，每条附带理由
agent-sudo --reason &quot;安装编译工具链&quot; -- apt install -y gcc make cmake
agent-sudo --reason &quot;创建 /opt/app 目录并设权限&quot; -- install -m 0755 -d /opt/app
agent-sudo --reason &quot;启动 nginx 服务&quot; -- systemctl enable --now nginx

# 全部排完后，一次性提交审批
agent-sudo-flush
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;agent-sudo-flush&lt;/code&gt; 会弹出一个 PySide2 原生 Qt5 窗口，集中展示队列中的所有命令。每条命令旁标注了 Agent 添加的 &lt;code&gt;--reason&lt;/code&gt;，人类可以在一个窗口中查看全部待审批命令的上下文，勾选需要执行的，取消不需要的，然后输入密码一次执行。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;images/agent-sudo/agent-sudo-gui.png&quot; alt=&quot;agent-sudo GUI&quot; /&gt;&lt;/p&gt;
&lt;p&gt;工作流时序：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sequenceDiagram
    participant Agent
    participant agent-sudo
    participant Human

    Agent-&amp;gt;&amp;gt;agent-sudo: agent-sudo --reason &quot;...&quot;
    Note over agent-sudo: 写入 queue.json
    Agent-&amp;gt;&amp;gt;agent-sudo: agent-sudo --reason &quot;...&quot;
    Note over agent-sudo: 追加 queue.json

    Agent-&amp;gt;&amp;gt;agent-sudo: agent-sudo-flush
    Note over agent-sudo: 读取 queue.json
    agent-sudo-&amp;gt;&amp;gt;Human: 弹出 PySide2 GUI
    Note over Agent: [阻塞等待]

    Note over Human: 查看命令、勾选/取消
    Note over Human: 输入 sudo 密码
    Human-&amp;gt;&amp;gt;agent-sudo: 点击执行

    Note over agent-sudo: 执行 sudo 命令
    Note over agent-sudo: 写入 history.json
    agent-sudo--&amp;gt;&amp;gt;Agent: 退出码
    Note over Agent: 继续工作
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;与 pkexec 的对比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;agent-sudo&lt;/th&gt;
&lt;th&gt;pkexec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;审批模式&lt;/td&gt;
&lt;td&gt;所有命令集中在一个窗口&lt;/td&gt;
&lt;td&gt;每条命令独立弹窗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;命令上下文&lt;/td&gt;
&lt;td&gt;每条命令附带 &lt;code&gt;--reason&lt;/code&gt; 说明&lt;/td&gt;
&lt;td&gt;仅显示命令本身&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;选择性执行&lt;/td&gt;
&lt;td&gt;支持，可单独取消不需要的命令&lt;/td&gt;
&lt;td&gt;逐条只能接受或拒绝&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;历史记录&lt;/td&gt;
&lt;td&gt;&lt;code&gt;history.json&lt;/code&gt; 记录每次审批详情&lt;/td&gt;
&lt;td&gt;仅 syslog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;超时处理&lt;/td&gt;
&lt;td&gt;60 秒无操作自动拒绝，有暂停按钮&lt;/td&gt;
&lt;td&gt;PolicyKit 默认超时机制&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;配置要求&lt;/td&gt;
&lt;td&gt;零配置，能运行 sudo 即可&lt;/td&gt;
&lt;td&gt;依赖 DBus + PolicyKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;密码处理&lt;/td&gt;
&lt;td&gt;通过 stdin 传递，内存中用完即清零&lt;/td&gt;
&lt;td&gt;由 PolicyKit agent 管理&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;sudoers&lt;/code&gt; 白名单方案则需要预先用 root 权限编辑 &lt;code&gt;/etc/sudoers&lt;/code&gt; 文件，将允许的命令列表写入。Agent 的需求是动态的，每次新增命令类别都需要修改 sudoers，且语法错误可能导致 sudo 不可用。对于 Agent 工作流中频繁变化的权限需求，白名单的维护成本较高。&lt;/p&gt;
&lt;h2&gt;更多细节&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;倒计时自动拒绝&lt;/strong&gt;。审批窗口弹出后启动 60 秒倒计时，超时无人操作则自动拒绝，Agent 收到退出码 126 后可以判断审批未通过。有暂停按钮允许人在来不及操作时暂停倒计时。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;密码安全&lt;/strong&gt;。密码通过 stdin 传递给 &lt;code&gt;sudo -S&lt;/code&gt;，执行完成后使用 &lt;code&gt;ctypes.memset&lt;/code&gt; 将内存中的密码清零，不写入磁盘。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LLM 反馈通道&lt;/strong&gt;。审批窗口底部有一个可折叠的文本输入区。人类可以在拒绝执行时留下说明，Agent 会从 stdout 中读取 &lt;code&gt;LLM_PROMPT:&lt;/code&gt; 开头的文本并根据反馈调整后续行为。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;审计记录&lt;/strong&gt;。每次审批的时间、命令列表、执行结果均记录在 &lt;code&gt;~/.cache/agent-sudo/history.json&lt;/code&gt; 中，便于事后追溯。&lt;/p&gt;
&lt;h2&gt;安装&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/agent-sudo.git ~/agent-sudo
pip install PySide2
sudo ln -sf ~/agent-sudo/src/main.py /usr/local/bin/agent-sudo
sudo ln -sf ~/agent-sudo/src/main.py /usr/local/bin/agent-sudo-flush
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;依赖 Python 3.8+、PySide2，需要有图形会话环境（&lt;code&gt;$DISPLAY&lt;/code&gt; 非空）。完整步骤见 INSTALL.md。&lt;/p&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;agent-sudo 解决的是 AI Agent 工作流中一个具体的效率问题：将多条特权命令的审批从多次中断合并为一次集中决策。它不替代 sudo，而是在 Agent 和 root 权限之间增加一层全局视角的审批环节。&lt;/p&gt;
&lt;p&gt;项目以 MIT 协议开源，欢迎试用和反馈。&lt;/p&gt;
</content:encoded></item><item><title>三个开源项目，同一套骨架 —— open-design、open-slide、OpenMontage 的架构分析</title><link>https://lorenzofeng.top/posts/agent-native-creative-runtime/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/agent-native-creative-runtime/</guid><description>深度分析 open-design、open-slide、OpenMontage 三个项目的共有架构模式，追溯从 Dan Shipper 到 Claude Design 的概念谱系，以及这套正在收敛的架构范式。</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近我花了两天时间，把 GitHub 上三个名字带「Open」的项目——open-design、open-slide、OpenMontage——的仓库、文档、commit 历史和外部评论翻了个遍。它们表面上看毫不相干：一个做设计原型，一个做幻灯片，一个做视频制作。但剖开来看，它们的底层架构几乎是同一套骨架。&lt;/p&gt;
&lt;p&gt;更值得注意的是，这套骨架在不到半年的时间里，被至少六个独立项目以不同形式重新发明了一遍。这不是巧合——这是问题空间的结构性约束导致的必然收敛。&lt;/p&gt;
&lt;h2&gt;三个项目，同一套骨架&lt;/h2&gt;
&lt;p&gt;先把这三个主角摆出来：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;项目&lt;/th&gt;
&lt;th&gt;GitHub&lt;/th&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;领域&lt;/th&gt;
&lt;th&gt;创建时间&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;open-design&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nexu-io/open-design&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;69k+&lt;/td&gt;
&lt;td&gt;设计原型 / UI / 幻灯片 / 视频&lt;/td&gt;
&lt;td&gt;2026.4.28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;open-slide&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1weiho/open-slide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.4k+&lt;/td&gt;
&lt;td&gt;React 幻灯片框架&lt;/td&gt;
&lt;td&gt;2026.4.26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenMontage&lt;/td&gt;
&lt;td&gt;&lt;code&gt;calesthio/OpenMontage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6.5k+&lt;/td&gt;
&lt;td&gt;端到端视频制作&lt;/td&gt;
&lt;td&gt;2026.3.29&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;它们的描述里都有一个关键的共同句法——「把你的 AI 编程助手（Claude Code / Codex / Cursor）变成 X」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;open-design：把你的 coding agent 变成设计引擎&lt;/li&gt;
&lt;li&gt;open-slide：把你的 coding agent 变成幻灯片作者&lt;/li&gt;
&lt;li&gt;OpenMontage：把你的 coding agent 变成视频制作工作室&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;它们自己都不包含 AI。&lt;/strong&gt; 它们是一个运行时、一组工具、一套指令——AI 是外部调用的，是用户自己选的。&lt;/p&gt;
&lt;p&gt;这是一个根本性的架构选择。如果你在做一个「AI 设计工具」，你会在代码里调用 GPT-5 或者 Claude Opus。但这类项目的工作是给 agent 准备好画布、管线、质检关卡和领域知识，然后告诉它：「这里有一切你需要的，开始吧。」&lt;/p&gt;
&lt;h2&gt;三层知识体系：不是设计选择，是结构必然&lt;/h2&gt;
&lt;p&gt;把三个项目的目录结构叠在一起看，你会发现它们不约而同地把知识分成了三层：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Layer 1: 工具层（What exists）
  → 工具注册表、能力矩阵、成本、回退策略
  → OpenMontage: tools/tool_registry.py（52 tools）
  → open-slide: @open-slide/core（Vite 插件、canvas、present mode）
  → open-design: daemon + 16 个 CLI adapter

Layer 2: 领域层（How WE use it）
  → 管线约定、阶段定义、导演 skill、质量关卡
  → OpenMontage: skills/pipelines/*/（stage director skills）
  → open-slide: /slide-authoring（1920×1080 canvas、type scale、palette）
  → open-design: 259+ skills（可组合设计工作流）

Layer 3: 供应商层（How the TECHNOLOGY works）
  → 原始 API 规则、prompt engineering、参数优化
  → OpenMontage: .agents/skills/（供应商特定的 prompting）
  → open-slide: AGENTS.md 硬规则（不添加依赖、只用 React+Web API）
  → open-design: DESIGN.md 品牌系统（colors、typography、components）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这三层有一个严格的&lt;strong&gt;读取顺序依赖&lt;/strong&gt;。Agent 必须先发现有什么工具（Layer 1），再理解这个项目怎么用它们（Layer 2），最后才能精确调用底层 API（Layer 3）。跳过任何一层，输出质量就会急剧下降。OpenMontage 的文档直接警告：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;「Layer 3 不是可选的。每一个生成工具都有一个 &lt;code&gt;agent_skills&lt;/code&gt; 字段指向它的 Layer 3 skill。一个通用 prompt 和一个 skill-informed prompt 之间的差距，就是『能用』和『电影级』之间的差距。」&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这不是某个项目的设计偏好——这是「外部 agent 需要逐层获取知识才能正确执行」这件事的&lt;strong&gt;架构必然性&lt;/strong&gt;。只要你的智能体是外部的，你就必然需要这三层，而且顺序不可变。&lt;/p&gt;
&lt;h2&gt;Pipeline as Contract：管线不只是自动化&lt;/h2&gt;
&lt;p&gt;这些项目都有「管线」，但它们的管线和传统管线（CI/CD、视频渲染管线）有一个本质区别。传统管线的每个阶段是&lt;strong&gt;代码在执行&lt;/strong&gt;，这类管线的每个阶段是&lt;strong&gt;agent 在阅读指令后决定如何执行&lt;/strong&gt;。管线不是自动化序列，是导航合约。&lt;/p&gt;
&lt;p&gt;OpenMontage 把这条写成了 Rule Zero：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;「所有生产必须通过一条管线。Agent 读取管线 manifest（YAML）→ 读取阶段导演 skill（MD）→ 调用工具 → 自审 → checkpoint → 提交人类审批。每个阶段产出一个规范制品，成为下一阶段的合约。」&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;注意「规范制品成为合约」——阶段之间不是通过共享内存传递状态，而是通过产出一个文件，这个文件成为下一阶段 agent 读取的输入。这意味着：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个阶段可以&lt;strong&gt;独立恢复&lt;/strong&gt;（agent 崩溃后从 checkpoint 继续）&lt;/li&gt;
&lt;li&gt;每个阶段可以&lt;strong&gt;独立审查&lt;/strong&gt;（人类可以在任意节点介入）&lt;/li&gt;
&lt;li&gt;每个阶段可以&lt;strong&gt;替换&lt;/strong&gt;（换一个 agent 或换一个 skill 不影响管线结构）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;open-slide 的 &lt;code&gt;/create-slide&lt;/code&gt; 也是同样的模式——先问四个问题（主题、页数、文字密度、动效强度），这些问题的答案就是第一个阶段的规范制品，后续的 slide 写作阶段以此为合约。&lt;/p&gt;
&lt;h2&gt;硬规则文件：圈定 agent 的自由范围&lt;/h2&gt;
&lt;p&gt;三个项目的根目录下都有 &lt;code&gt;AGENTS.md&lt;/code&gt; 或 &lt;code&gt;AGENT_GUIDE.md&lt;/code&gt;，内容极其相似——全是约束性规则。open-slide 的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- Slides go under slides/&amp;lt;id&amp;gt;/
- Entry is slides/&amp;lt;id&amp;gt;/index.tsx
- Don&apos;t touch package.json, open-slide.config.ts, or other slides
- Don&apos;t add dependencies. Use only React and standard web APIs.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OpenMontage 的更绝——整个文件只有一条规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MANDATORY: Read AGENT_GUIDE.md before responding to ANY user message.
There are no instructions in this file. All instructions are in AGENT_GUIDE.md.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些规则的设计意图不是限制 agent，而是&lt;strong&gt;圈定它的自由范围&lt;/strong&gt;。边界之内（怎么写 slide、怎么配颜色、怎么布局），agent 有完全的自由。边界之外（不能改配置、不能加依赖、不能碰其他 slide），零容忍。&lt;/p&gt;
&lt;p&gt;传统开发中，人类开发者知道「不要随便改 package.json」是因为经验。Agent 没有这个判断力——它看到一个 import 缺失，本能反应是 &lt;code&gt;npm install&lt;/code&gt;。AGENTS.md 就是提前切断 agent 的破坏性自由度。&lt;/p&gt;
&lt;h2&gt;追溯源头：这条河是怎么流过来的&lt;/h2&gt;
&lt;p&gt;把关键事件按时间排列，会看到一条非线性的演化路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2025.12  Dan Shipper 发表 &quot;Compound Engineering&quot;（先导文章）
2026.01  Dan Shipper 发表 &quot;Agent-native Architectures&quot;
         → 首次明确定义 agent-native 概念
2026.02  creative-liberation-engine（14 daemon 集群，独立实验）
2026.03  BuilderIO agent-native 框架 + OpenMontage
         → OpenMontage 早于 Claude Design 近三周！
2026.04  Claude Design 发布 → open-codesign → open-slide → open-design
         → 10 天内 4 个项目密集诞生
2026.05  saker + Nous Research Kanban Pipeline
         → 大实验室正式入场
2026.06  本文写作时
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个关键发现：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一，Dan Shipper 是概念定义者。&lt;/strong&gt; 他在 2026 年 1 月的文章里精确描述了后来三个项目的核心结构：「agent-native 应用的核心不是代码，而是 agent。每个 feature 是给 agent 的 prompt，而不是实现步骤。我经常把 agent-native 应用想成『Claude Code 穿着一件 trenchcoat』。」&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二，OpenMontage 早于 Claude Design。&lt;/strong&gt; 它 3 月 29 日就创建了——比 Anthropic 的商业产品早了近三周。说明这个模式不是 Anthropic 发明的，它是在 agent 能力到达临界点后自然涌现的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三，Claude Design 是商业引爆点。&lt;/strong&gt; 它 4 月 17 日发布，次日 open-codesign 就出现了。10 天内，三个开源替代品（open-codesign、open-slide、open-design）密集诞生。这不是社区对 Anthropic 的模仿，这是社区对「封闭 + 单模型锁定」的结构性反弹。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第四，大实验室已经入场。&lt;/strong&gt; Nous Research 在 5 月发布了 Kanban Video Pipeline——一个基于 Hermes Agent 的多 agent 视频制作管线，Director/Cinematographer/Renderer/Editor 四个 profile 通过结构化 handoff 协作。架构上和 OpenMontage 完全同构，只是执行拓扑从单 agent 顺序变成了多 agent profile 并行。&lt;/p&gt;
&lt;h2&gt;市场正在验证：两个不可调和的分叉&lt;/h2&gt;
&lt;p&gt;在我翻阅的外部评论中，R2ClickThrough 的一篇分析（2026.4.29）意外地抓住了关键：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;「两个克隆（open-codesign 和 open-design）的架构截然不同，这种差异就是整个故事。open-codesign 捆绑了一个特定的 AI 运行时；open-design 的 web app 和 daemon 只是一个薄的协调层，agent 是你 PATH 上已经有的任何东西。」&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这标志着「捆绑派」和「委托派」的路线分裂：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;捆绑派（open-codesign）&lt;/th&gt;
&lt;th&gt;委托派（open-design）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent 提供方式&lt;/td&gt;
&lt;td&gt;内置固定&lt;/td&gt;
&lt;td&gt;PATH 扫描，自适应 16 种 CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;升级成本&lt;/td&gt;
&lt;td&gt;等上游发布&lt;/td&gt;
&lt;td&gt;零——新 CLI 出现即自动支持&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;调试难度&lt;/td&gt;
&lt;td&gt;低（单一组合）&lt;/td&gt;
&lt;td&gt;中（多 agent 兼容）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;长期风险&lt;/td&gt;
&lt;td&gt;agent 绑定&lt;/td&gt;
&lt;td&gt;薄层可能被大厂吸收&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;R2ClickThrough 的判断：「当 Cursor 明天发布更好的模式，你免费获得。当 Gemini CLI 新增一个工具，OD 在下一次 daemon 启动时自动获取。没有 agent 需要升级，因为这个项目从未拥有过一个 agent。」&lt;/p&gt;
&lt;p&gt;open-design 用 40k star 在三周内的增长曲线来支撑这个论断——市场显然认可「委托优于捆绑」。&lt;/p&gt;
&lt;h2&gt;六个正在形成的识别特征&lt;/h2&gt;
&lt;p&gt;把 open-design、open-slide、OpenMontage、saker、Noustiny、creative-liberation-engine 六个项目放在一起，可以提炼出一组识别特征。如果一个新项目同时满足这六条，它就在这个模式里：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Agent as Runtime&lt;/strong&gt; — AI 是外部调用方，项目不内嵌模型。指令文件是第一公民，代码工具退居第二&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Three-Layer Knowledge&lt;/strong&gt; — 工具层 → 领域层 → 供应商层，顺序读取，Layer 3 不可跳过&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pipeline as Contract&lt;/strong&gt; — 管线是 agent 的导航合约，阶段间通过规范制品传递状态&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared Actions&lt;/strong&gt; — UI 和 Agent 操作同一套 actions，对称而非叠床架屋&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Built-in Self-Review&lt;/strong&gt; — 质检内建在管线每个阶段，agent 不自审就无法 checkpoint&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hard Rules for Agent&lt;/strong&gt; — AGENTS.md 定义 agent 的操作边界：边界内完全自主，边界外零容忍&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;生态已经在动了&lt;/h2&gt;
&lt;p&gt;过去两个月的几个信号：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Skill 可移植性成为现实&lt;/strong&gt;：OpenClaw 发布了 OpenMontage 插件——纯 Markdown skill 包，零 JS 代码，跨 agent 平台运行&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跨项目技能互操作协议出现&lt;/strong&gt;：Skills Interoperability Protocol（SIP）定义了四种组合模式（Layered / Pipeline / Handoff / Advisory）和冲突解决优先级链&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Figma 感受到压力&lt;/strong&gt;：2026 年 5 月发布 Figma Design Agent on-canvas，首次把 agent 直接放进画布&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;a16z 的 2026 大预测&lt;/strong&gt;：在 2025 年 12 月的 &quot;Big Ideas 2026&quot; 中，将 &quot;Agent-native infrastructure becomes table stakes&quot; 列为第一个预测&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个品类还缺一个共识的名字。Dan Shipper 叫它「agent-native architecture」，BuilderIO 叫它「agent-native application」，a16z 叫它「agent-native infrastructure」——共同的关键词是 agent-native。但它已经在六个项目、四篇文章、两家投资机构的论述中出现了，正在从现象变成共识。&lt;/p&gt;
&lt;h2&gt;接下来会发生什么&lt;/h2&gt;
&lt;p&gt;基于目前的分析，我做出四个预测，供半年后回来看：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;第三波领域扩张&lt;/strong&gt;：音频/音乐制作（&quot;OpenStage&quot;）和 3D/游戏资产（&quot;OpenFrame&quot;）是最自然的下一站。不是会不会出现，而是谁先出现。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;跨 Runtime 的 Pipeline Manifest 标准&lt;/strong&gt;：12 个月内会出现一套类似 Docker Compose 的管线描述语言，让一个 YAML 文件能在 OpenMontage、saker、open-design 之间迁移。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;大厂的路线选择&lt;/strong&gt;：Anthropic 和 OpenAI 目前都在做「单次调用优化」，离「12 阶段管线 + 自审 + checkpoint」有显著距离。但如果他们把 pipeline-level orchestration 做进 MCP 协议，独立 runtime 的架构优势可能被吸收。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;平台与垂直的博弈&lt;/strong&gt;：open-design 的 69k star 代表平台的势能，OpenMontage 的 12 条专业管线代表垂直的深度。最终大概率形成「一个平台 + 多个垂直 skill 生态」的结构——就像 VS Code 和它的扩展市场。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;相关仓库&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;nexu-io/open-design&quot;}&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;1weiho/open-slide&quot;}&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;calesthio/OpenMontage&quot;}&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;BuilderIO/agent-native&quot;}&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;NousResearch/hermes-agent&quot;}&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;这是一篇分析文章，不是评测。如果你正在选择用哪个工具开始做 AI 驱动的创意生产，我建议从 open-design 开始（平台最全），然后用 OpenMontage 做视频（管线最深），用 open-slide 做技术演示（React 生态最顺滑）。三个都用同一个 coding agent，你的 agent 会自己学会切换上下文。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>给 OpenCode 换个 Logo —— TUI 插件开发入门</title><link>https://lorenzofeng.top/posts/opencode-tui-logo-customization/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/opencode-tui-logo-customization/</guid><description>通过 TUI 插件的 slot 机制给 OpenCode 终端界面换上自定义 Logo，附带双色 ASCII 艺术字和主题色适配教程</description><pubDate>Tue, 02 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近给 OpenCode 换了个启动 Logo，把默认的 OpenCode 字样换成了自己的品牌名「LorenzoFeng」。整个过程踩了几个坑，记录一下完整的 TUI 插件开发流程。&lt;/p&gt;
&lt;h2&gt;为什么不能用配置文件&lt;/h2&gt;
&lt;p&gt;直觉上，换个 Logo 应该是配置项的事。OpenCode 确实有过一个 PR（&lt;a href=&quot;https://github.com/anomalyco/opencode/pull/12017&quot;&gt;#12017&lt;/a&gt;）试图在 &lt;code&gt;opencode.jsonc&lt;/code&gt; 里加 &lt;code&gt;&quot;logo&quot;&lt;/code&gt; 字段，但被维护者关了，理由是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;You can customize it with tui plugins.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;也就是说，OpenCode 的 TUI 定制走的是&lt;strong&gt;插件机制&lt;/strong&gt;而非&lt;strong&gt;配置项&lt;/strong&gt;。Logo、侧边栏、底部状态栏都通过 slot 机制暴露给插件。&lt;/p&gt;
&lt;h2&gt;TUI 插件的 Slot 机制&lt;/h2&gt;
&lt;p&gt;OpenCode 的 TUI 界面由一系列 &lt;strong&gt;slot（插槽）&lt;/strong&gt; 组成，插件可以注册到其中进行替换或追加：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Slot 名称&lt;/th&gt;
&lt;th&gt;位置&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_logo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;首页 Logo&lt;/td&gt;
&lt;td&gt;我们要替换的目标&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_prompt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;首页输入框&lt;/td&gt;
&lt;td&gt;可替换为自定义输入组件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_bottom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;首页底部&lt;/td&gt;
&lt;td&gt;OS / Provider 信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_footer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;页脚&lt;/td&gt;
&lt;td&gt;状态栏下方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sidebar_title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;侧边栏标题&lt;/td&gt;
&lt;td&gt;会话标题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sidebar_content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;侧边栏内容&lt;/td&gt;
&lt;td&gt;文件变更 / MCP / LSP 状态&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;注册方式分两种模式：&lt;code&gt;&quot;replace&quot;&lt;/code&gt; 替换整个 slot，不传 mode 则添加进去。&lt;/p&gt;
&lt;h2&gt;插件目录结构&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;关键踩坑点&lt;/strong&gt;：TUI 插件&lt;strong&gt;必须&lt;/strong&gt;有 &lt;code&gt;package.json&lt;/code&gt; 并声明 &lt;code&gt;exports[&quot;./tui&quot;]&lt;/code&gt;，光放一个 &lt;code&gt;.tsx&lt;/code&gt; 文件不生效。正确的结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.config/opencode/plugins/custom-logo/
├── package.json
└── tui.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;package.json&lt;/code&gt; 长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;custom-logo&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;type&quot;: &quot;module&quot;,
  &quot;exports&quot;: {
    &quot;./tui&quot;: &quot;./tui.tsx&quot;
  },
  &quot;peerDependencies&quot;: {
    &quot;@opencode-ai/plugin&quot;: &quot;*&quot;,
    &quot;@opentui/core&quot;: &quot;*&quot;,
    &quot;@opentui/solid&quot;: &quot;*&quot;,
    &quot;solid-js&quot;: &quot;*&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OpenCode 通过 &lt;code&gt;exports[&quot;./tui&quot;]&lt;/code&gt; 来定位 TUI 入口，没有这个声明插件就不会被加载。&lt;/p&gt;
&lt;h2&gt;插件代码&lt;/h2&gt;
&lt;p&gt;插件基于 &lt;a href=&quot;https://www.solidjs.com/&quot;&gt;Solid.js&lt;/a&gt; 和 &lt;code&gt;@opentui/solid&lt;/code&gt; 渲染。把「Lorenzo」和「Feng」拆成两个独立数组，用 flex 布局左右排列，分别上蓝色和正文色：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginModule, TuiThemeCurrent } from &quot;@opencode-ai/plugin/tui&quot;

const LORENZO = [
`
██╗      ██████╗ ██████╗ ███████╗███╗   ██╗███████╗ ██████╗ 
██║     ██╔═══██╗██╔══██╗██╔════╝████╗  ██║╚══███╔╝██╔═══██╗
██║     ██║   ██║██████╔╝█████╗  ██╔██╗ ██║  ███╔╝ ██║   ██║
██║     ██║   ██║██╔══██╗██╔══╝  ██║╚██╗██║ ███╔╝  ██║   ██║
███████╗╚██████╔╝██║  ██║███████╗██║ ╚████║███████╗╚██████╔╝
╚══════╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝╚═╝  ╚═══╝╚══════╝ ╚═════╝ 
`
]

const FENG = [
`
███████╗███████╗███╗   ██╗ ██████╗ 
██╔════╝██╔════╝████╗  ██║██╔════╝ 
█████╗  █████╗  ██╔██╗ ██║██║  ███╗
██╔══╝  ██╔══╝  ██║╚██╗██║██║   ██║
██║     ███████╗██║ ╚████║╚██████╔╝
╚═╝     ╚══════╝╚═╝  ╚═══╝ ╚═════╝ 
`
]

const SUBTITLE = &quot;Web Site: https://lorenzofeng.top/&quot;

function HomeLogo(props: { theme: TuiThemeCurrent }) {
  const lorenzoColor = props.theme.info      // 蓝色
  const fengColor = props.theme.text          // 正文色
  const muted = props.theme.textMuted
  const gap = &quot;    &quot;

  return (
    &amp;lt;box flexDirection=&quot;column&quot; alignItems=&quot;center&quot;&amp;gt;
      &amp;lt;box flexDirection=&quot;row&quot;&amp;gt;
        {/* LORENZO —— 蓝色 */}
        &amp;lt;box flexDirection=&quot;column&quot;&amp;gt;
          {LORENZO.map((line) =&amp;gt; (
            &amp;lt;text fg={lorenzoColor}&amp;gt;{line}&amp;lt;/text&amp;gt;
          ))}
        &amp;lt;/box&amp;gt;
        {/* 间距 */}
        &amp;lt;box flexDirection=&quot;column&quot;&amp;gt;
          {LORENZO.map(() =&amp;gt; (
            &amp;lt;text fg={muted}&amp;gt;{gap}&amp;lt;/text&amp;gt;
          ))}
        &amp;lt;/box&amp;gt;
        {/* FENG —— 正文色 */}
        &amp;lt;box flexDirection=&quot;column&quot;&amp;gt;
          {FENG.map((line) =&amp;gt; (
            &amp;lt;text fg={fengColor}&amp;gt;{line}&amp;lt;/text&amp;gt;
          ))}
        &amp;lt;/box&amp;gt;
      &amp;lt;/box&amp;gt;
      &amp;lt;text&amp;gt; &amp;lt;/text&amp;gt;
      &amp;lt;box flexDirection=&quot;row&quot; gap={1}&amp;gt;
        &amp;lt;text fg={muted}&amp;gt;━&amp;lt;/text&amp;gt;
        &amp;lt;text fg={muted} bold&amp;gt;{SUBTITLE}&amp;lt;/text&amp;gt;
        &amp;lt;text fg={muted}&amp;gt;━&amp;lt;/text&amp;gt;
      &amp;lt;/box&amp;gt;
      &amp;lt;text&amp;gt; &amp;lt;/text&amp;gt;
    &amp;lt;/box&amp;gt;
  )
}

const tui: TuiPlugin = async (api) =&amp;gt; {
  api.slots.register({
    mode: &quot;replace&quot;,
    slots: {
      home_logo(ctx) {
        return &amp;lt;HomeLogo theme={ctx.theme.current} /&amp;gt;
      },
    },
  })
}

export default { id: &quot;custom-logo&quot;, tui }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个要点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/** @jsxImportSource @opentui/solid */&lt;/code&gt; 声明 JSX 编译目标，这句不能丢&lt;/li&gt;
&lt;li&gt;Logo 拆成两个数组 → &lt;code&gt;&amp;lt;box flexDirection=&quot;row&quot;&amp;gt;&lt;/code&gt; 横向排列 → 左边的 map 遍历 &lt;code&gt;LORENZO&lt;/code&gt;，右边的 map 遍历 &lt;code&gt;FENG&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;slots.register()&lt;/code&gt; 接受 &lt;code&gt;{ mode, slots }&lt;/code&gt; 对象，&lt;code&gt;&quot;replace&quot;&lt;/code&gt; 表示完全替换 OpenCode 默认 Logo&lt;/li&gt;
&lt;li&gt;slot 渲染函数接收 &lt;code&gt;ctx&lt;/code&gt; 上下文，通过 &lt;code&gt;ctx.theme.current&lt;/code&gt; 拿到当前主题色&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;颜色配置&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;变量&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.primary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;主色&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.accent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;强调色&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.info&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;蓝色系&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.success&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;绿色系&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.warning&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;黄色系&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;红色系&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;也可以用硬编码色值 &lt;code&gt;fg=&quot;#ff6b6b&quot;&lt;/code&gt;，但不会随主题切换。&lt;/p&gt;
&lt;h2&gt;ASCII 艺术字生成&lt;/h2&gt;
&lt;p&gt;Logo 用的字体风格是 &lt;strong&gt;ANSI Shadow&lt;/strong&gt;，生成工具推荐 &lt;a href=&quot;https://patorjk.com/software/taag&quot;&gt;patorjk.com/software/taag&lt;/a&gt;，有上百种字体可选。推荐几个适合终端显示的：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字体&lt;/th&gt;
&lt;th&gt;效果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANSI Shadow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;带阴影的块状字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Big&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;简洁大方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Block&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;实心方块&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Doom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;游戏风格&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Graffiti&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;涂鸦风&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;注册插件&lt;/h2&gt;
&lt;p&gt;插件写好后需要注册。有两种方式：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;方式一——自动发现&lt;/strong&gt;：把插件放在 &lt;code&gt;~/.config/opencode/plugins/&lt;/code&gt; 目录下，OpenCode 启动时自动扫描加载。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;方式二——显式声明&lt;/strong&gt;：在 &lt;code&gt;opencode.jsonc&lt;/code&gt; 或 &lt;code&gt;tui.json&lt;/code&gt; 的 &lt;code&gt;plugin&lt;/code&gt; 数组中加绝对路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;plugin&quot;: [
    &quot;~/.config/opencode/plugins/custom-logo&quot;
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两种方式我都用了，确保万无一失。插件支持热更新，改 &lt;code&gt;tui.tsx&lt;/code&gt; 后重进 TUI 即可生效，不需要重启 OpenCode。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;OpenCode 的 TUI 插件系统比预期灵活很多——slot 机制让几乎所有界面元素都可替换。虽然 Logo 不能通过配置文件直接改，但写一个十几行的插件就能搞定，而且能做的事情远不止换 Logo：侧边栏、输入框、状态栏都能深度定制。&lt;/p&gt;
</content:encoded></item><item><title>AgentEnv 路线图——从包管理器到完整环境管理，寻找合作者</title><link>https://lorenzofeng.top/posts/agentenv-runtime-roadmap/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/agentenv-runtime-roadmap/</guid><description>AgentEnv 的下一个目标：像 conda 管 Python 版本一样管理 OpenCode/Cursor 的版本，让 agent.yaml 成为 Agent 开发环境的唯一真相来源。寻找 Go 后端合作者。</description><pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;::github{repo=&quot;7emotions/agentenv&quot;}&lt;/p&gt;
&lt;h2&gt;AgentEnv 是什么&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/7emotions/agentenv&quot;&gt;AgentEnv&lt;/a&gt; 是一个 &lt;strong&gt;AI Agent 的环境管理器&lt;/strong&gt;——把它想象成 conda，但管理的是 skills、MCP 服务器、agent 定义、tools、hooks 和 prompts。六种包类型、四种框架适配器（Claude Code、OpenCode、Cursor、Codex）、六种源协议——声明式 &lt;code&gt;agent.yaml&lt;/code&gt; + PubGrub 依赖解析 + 锁文件。&lt;/p&gt;
&lt;p&gt;目前它能做什么：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;agentenv create my-project --agent opencode
agentenv add skill code-reviewer --source github:myorg/code-reviewer
agentenv lock &amp;amp;&amp;amp; agentenv install &amp;amp;&amp;amp; agentenv activate my-project
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;但它缺了最关键的一步——框架版本管理。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;缺失的拼图&lt;/h2&gt;
&lt;p&gt;假设你的团队用 OpenCode 开发 Agent。你在 &lt;code&gt;agent.yaml&lt;/code&gt; 里声明了 skills 和 MCPs，&lt;code&gt;agentenv lock&lt;/code&gt; 锁定了所有依赖的版本。你的同事 clone 了项目，&lt;code&gt;agentenv activate&lt;/code&gt; 激活了环境。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但你们的 OpenCode 版本可能不一样。&lt;/strong&gt; 你的 &lt;code&gt;agent.yaml&lt;/code&gt; 不定义框架版本——框架版本是隐式的，取决于每个人本地装了什么。如果 OpenCode 0.8 的某个 MCP 行为变了，你的环境在同事的机器上可能表现不一致。&lt;/p&gt;
&lt;p&gt;类比 conda：conda 不只管 numpy 的版本，也管 Python 本身的版本。&lt;code&gt;environment.yml&lt;/code&gt; 里的 &lt;code&gt;python=3.10&lt;/code&gt; 是环境定义的核心部分。AgentEnv 也应该管框架版本。&lt;/p&gt;
&lt;h2&gt;解决方案——框架版本管理&lt;/h2&gt;
&lt;p&gt;核心思路非常简单：在 &lt;code&gt;agent.yaml&lt;/code&gt; 中声明框架及其版本约束。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: my-project
framework:
  type: opencode
  version: &quot;&amp;gt;=0.7.0&quot;

skills:
  code-reviewer:
    source: github:myorg/code-reviewer
    version: &quot;^1.0&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;agentenv activate&lt;/code&gt; 时检查本地安装的 OpenCode 版本是否满足约束。如果不满足，直接报错告诉你需要哪个版本。&lt;/p&gt;
&lt;p&gt;这和 conda 的 &lt;code&gt;python=3.10&lt;/code&gt; 一模一样——AgentEnv 管框架版本，OpenCode/Cursor/Claude Code 仍然是激活目标，开发者仍然用自己熟悉的工具。&lt;/p&gt;
&lt;h2&gt;实现计划&lt;/h2&gt;
&lt;p&gt;完整的实现计划在 &lt;a href=&quot;https://github.com/7emotions/agentenv/blob/main/docs/superpowers/plans/2026-05-26-runtime-management.md&quot;&gt;docs/superpowers/plans/2026-05-26-runtime-management.md&lt;/a&gt;。核心是 8 个任务，约 300 行新增代码：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;任务&lt;/th&gt;
&lt;th&gt;变更&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Types&lt;/td&gt;
&lt;td&gt;+15 行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FrameworkSpec&lt;/code&gt; 类型 + &lt;code&gt;Environment.Framework&lt;/code&gt; 字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Envfile&lt;/td&gt;
&lt;td&gt;+60 行&lt;/td&gt;
&lt;td&gt;reader/writer/validator 支持 &lt;code&gt;framework&lt;/code&gt; 字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create&lt;/td&gt;
&lt;td&gt;+10 行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--agent opencode&lt;/code&gt; 写入 &lt;code&gt;agent.yaml&lt;/code&gt;（不只是 &lt;code&gt;state.json&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adapter&lt;/td&gt;
&lt;td&gt;+30 行&lt;/td&gt;
&lt;td&gt;新增 &lt;code&gt;GetInstalledVersion()&lt;/code&gt; 方法，检查本地框架版本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activate&lt;/td&gt;
&lt;td&gt;+30 行&lt;/td&gt;
&lt;td&gt;激活时校验框架版本是否满足约束&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Info&lt;/td&gt;
&lt;td&gt;+15 行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;agentenv info&lt;/code&gt; 显示框架声明&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;+80 行&lt;/td&gt;
&lt;td&gt;framework 字段 round-trip + create 集成测试&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;+30 行&lt;/td&gt;
&lt;td&gt;README + agent-yaml.md 更新&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;不做的事情：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不造 AgentMini 或内置任何运行时。激活目标仍然是 OpenCode/Cursor/Claude Code/Codex。&lt;/li&gt;
&lt;li&gt;不加 deploy 命令。环境管理不变成部署工具。&lt;/li&gt;
&lt;li&gt;不把 gocode 特殊对待。如果将来有人想用 gocode——写一个适配器就行，和 OpenCode 完全对等。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;为什么这个方向是正确的&lt;/h2&gt;
&lt;p&gt;经过几轮架构讨论，我意识到之前&quot;部署给甲方&quot;的思路把 AgentEnv 带偏了。AgentEnv 的价值不是让非开发者跑 Agent——而是让开发者之间的环境可复现。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;conda → 管 Python 版本 + numpy 版本
AgentEnv → 管 OpenCode 版本 + skill/MCP 版本
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个类比，同一个抽象层。框架版本管理是这个类比的最后一环——补上它，AgentEnv 的环境模型就完整了。&lt;/p&gt;
&lt;h2&gt;寻找合作者&lt;/h2&gt;
&lt;p&gt;这个项目目前是我一个人在维护。路线图上的事情比我一个人能干的多。我在找对这些方向感兴趣的人：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心需求（Go 后端）：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;适配器扩展（实现各框架的 &lt;code&gt;GetInstalledVersion&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agentenv activate&lt;/code&gt; 的版本校验逻辑完善&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agentenv lock&lt;/code&gt; 对框架版本的支持（将 framework 纳入锁文件）&lt;/li&gt;
&lt;li&gt;新增 Aider、Gemini CLI 等框架适配器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;锦上添花（全栈）：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;agentenv doctor&lt;/code&gt; 健康检查命令（并行检查 runtime + adapters + store）&lt;/li&gt;
&lt;li&gt;无参运行时的信息面板（TTY 检测 + 环境状态概览）&lt;/li&gt;
&lt;li&gt;Golden 测试基础设施&lt;/li&gt;
&lt;li&gt;下一步提示（&lt;code&gt;create&lt;/code&gt; 后提示 &lt;code&gt;add&lt;/code&gt;，&lt;code&gt;lock&lt;/code&gt; 后提示 &lt;code&gt;install&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你对以上任何方向感兴趣，欢迎：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 &lt;a href=&quot;https://github.com/7emotions/agentenv/issues&quot;&gt;GitHub Issues&lt;/a&gt; 里开 discussion&lt;/li&gt;
&lt;li&gt;直接提 PR——路线图上的每一项都有详细的实现计划&lt;/li&gt;
&lt;li&gt;发邮件给我：lorenzo@lorenzofeng.top&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Agent 开发正在从&quot;每个人在自己机器上调配置&quot;的阶段走向工程化。AgentEnv 想成为 conda 对 Python 生态做的事——让 Agent 开发环境可以被声明、版本化、复现。如果你认同这个方向，一起来做。&lt;/p&gt;
</content:encoded></item><item><title>AgentEnv —— 当 AI 智能体也需要「conda 环境」</title><link>https://lorenzofeng.top/posts/agentenv-intro/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/agentenv-intro/</guid><description>从「什么是包」讲起，详解 AgentEnv 的六种包类型（skill/mcp/agent/tool/hook/prompt）、声明式配置格式、6 种源协议、4 种框架适配器，以及 PubGrub 依赖解析和内容寻址存储。</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这几个月我在不同项目间频繁切换时发现一个尴尬的问题：每个项目需要的 skill、MCP 服务器、agent 配置都不一样。给项目 A 装了某个 skill，项目 B 不兼容；在 Claude Code 里配好的 MCP 服务器，切到 OpenCode 又要重新来一遍。我管这个叫「AI 智能体的依赖地狱」。&lt;/p&gt;
&lt;p&gt;AgentEnv 就是为了解决这个问题而生的。&lt;/p&gt;
&lt;h2&gt;问题的本质&lt;/h2&gt;
&lt;p&gt;::github{repo=&quot;7emotions/agentenv&quot;}&lt;/p&gt;
&lt;p&gt;AI 编码工具的生态正在爆炸式增长。Claude Code、OpenCode、Cursor、Codex 各有各的配置格式和路径。skill、MCP 服务器、agent 定义、hook、prompt 模板——这些「智能体依赖」混在一起，没有隔离、没有版本管理、没有可重现性。&lt;/p&gt;
&lt;p&gt;Python 社区有 conda 和 virtualenv 来解决环境隔离；Node.js 有 npm workspace；Go 有 go modules。但 AI 智能体的环境管理，一直是一个手工拷贝配置文件的原始状态。&lt;/p&gt;
&lt;p&gt;AgentEnv 要做的就是这件事：&lt;strong&gt;像 conda 管理 Python 环境一样，管理 AI 智能体的依赖环境。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;设计哲学&lt;/h2&gt;
&lt;h3&gt;声明式，不拼凑&lt;/h3&gt;
&lt;p&gt;AgentEnv 的核心是 &lt;code&gt;agent.yaml&lt;/code&gt; —— 一个声明式的环境定义文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: my-ai-env
description: &quot;我的 AI 开发环境&quot;

skills:
  code-reviewer:
    source: github:myorg/code-review-skill
    version: &quot;&amp;gt;=0.5&quot;

mcps:
  filesystem:
    source: npm:@modelcontextprotocol/server-filesystem
    version: &quot;0.6.0&quot;
    config:
      root: /home/user/projects
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你只需要声明&lt;strong&gt;想要什么&lt;/strong&gt;，AgentEnv 负责&lt;strong&gt;找到、下载、安装、激活&lt;/strong&gt;。这跟 &lt;code&gt;package.json&lt;/code&gt; + &lt;code&gt;npm install&lt;/code&gt; 的思路一脉相承，但适配的是 AI 编码工具的生态。&lt;/p&gt;
&lt;h3&gt;可重现，版本锁定&lt;/h3&gt;
&lt;p&gt;声明之后执行 &lt;code&gt;agentenv lock&lt;/code&gt;，它会跑一个 &lt;strong&gt;PubGrub CDCL 解析器&lt;/strong&gt;——就是 Dart/Flutter 用那个算法——来求解依赖图：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自动发现传递依赖（skill A 依赖 skill B，B 又依赖 C）&lt;/li&gt;
&lt;li&gt;处理语义化版本约束（&lt;code&gt;^1.0&lt;/code&gt;、&lt;code&gt;&amp;gt;=2.0&lt;/code&gt;、&lt;code&gt;~3.5.1&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;检测冲突并给出精确的冲突报告&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;error: conflict detected
  code-reviewer@&amp;gt;=1.0 requires lint-helper@^2.0
  my-other-skill@&amp;gt;=0.5 requires lint-helper@^1.0
  lint-helper@^2.0 and lint-helper@^1.0 are incompatible
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解析完成后生成 &lt;code&gt;agent.lock&lt;/code&gt;，包含每个包的 SHA-256 校验、解析版本、来源——这是你在另一台机器上得到&lt;strong&gt;完全相同环境&lt;/strong&gt;的保证。&lt;/p&gt;
&lt;h3&gt;六种源，不分来源&lt;/h3&gt;
&lt;p&gt;AgentEnv 支持 6 种包来源协议：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;协议&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;github:myorg/code-review-skill&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm:@modelcontextprotocol/server-filesystem&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;local:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;local:./vendor/my-tool&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git:https://github.com/org/repo.git&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;file:/home/user/packages/skill.tar.gz&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;url:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;url:https://example.com/pkg.tar.gz&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;也就是说，你可以把一个 GitHub 上的 skill、一个 npm 上的 MCP 服务器、一个本地开发中的 hook 脚本，扔进&lt;strong&gt;同一个环境&lt;/strong&gt;管理。这对「正在开发 skill 同时又在用别人的 MCP」这种常见场景极其友好。&lt;/p&gt;
&lt;h3&gt;六种包，各司其职&lt;/h3&gt;
&lt;p&gt;AgentEnv 中，「包」（package）是一个抽象概念，覆盖了 AI 智能体生态中所有需要版本管理和环境隔离的东西。目前支持六种包类型：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;典型场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;skill&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;可安装的技能&lt;/td&gt;
&lt;td&gt;代码审查 skill、文档生成 skill、Git 操作 skill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MCP 服务器配置&lt;/td&gt;
&lt;td&gt;文件系统访问、浏览器自动化、数据库查询&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;智能体定义&lt;/td&gt;
&lt;td&gt;自定义 agent 的行为描述和系统提示词&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;工具包&lt;/td&gt;
&lt;td&gt;代码格式化工具、lint 工具配置&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hook&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;生命周期钩子&lt;/td&gt;
&lt;td&gt;激活前校验脚本、停用后清理脚本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prompt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;提示词模板&lt;/td&gt;
&lt;td&gt;代码审查提示词、提交信息生成模板&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;每种包在 &lt;code&gt;agent.yaml&lt;/code&gt; 中的声明格式完全一致，只需要三个字段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 必填：source —— 从哪里获取这个包
source: github:owner/repo

# 可选：version —— 语义化版本约束（默认 *，即任意版本）
version: &quot;&amp;gt;=0.5&quot;

# 可选：config —— 包专属的配置项，由包的消费者自行解读
config:
  root: /home/user/projects
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个包含所有六种类型的完整 &lt;code&gt;agent.yaml&lt;/code&gt; 长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: my-ai-env
description: &quot;我的全栈 AI 开发环境&quot;

# 技能（skill）：可复用的智能体能力模块
skills:
  code-reviewer:
    source: github:myorg/code-review-skill
    version: &quot;&amp;gt;=0.5&quot;

# MCP 服务器：连接外部工具的桥梁
mcps:
  filesystem:
    source: npm:@modelcontextprotocol/server-filesystem
    version: &quot;0.6.0&quot;
    config:
      root: /home/user/projects

# 智能体（agent）：自定义 agent 的完整规格
agents:
  senior-dev:
    source: github:myorg/agents
    version: &quot;~1.2&quot;

# 工具（tool）：辅助工具和配置
tools:
  formatter:
    source: url:https://example.com/tools/formatter.tar.gz

# 钩子（hook）：生命周期事件触发脚本
hooks:
  pre-activate:
    source: file:/home/user/hooks/check-env.sh
  post-deactivate:
    source: local:./hooks/cleanup.sh

# 提示词（prompt）：可复用的提示词模板
prompts:
  code-review:
    source: git:https://github.com/myorg/prompts.git
    version: &quot;&amp;gt;=2.0&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个环境可以混用任意类型的包，不限制种类和数量。同一个类型下包名必须唯一，但不同类型之间可以重名（比如可以同时有一个叫 &lt;code&gt;code-reviewer&lt;/code&gt; 的 skill 和一个叫 &lt;code&gt;code-reviewer&lt;/code&gt; 的 prompt）。&lt;/p&gt;
&lt;p&gt;这种设计的妙处在于——&lt;strong&gt;不论是 GitHub 上的开源 skill、npm 上的 MCP 包、还是本地开发中的 hook 脚本，对 AgentEnv 来说都是「包」，都走同一套解析、下载、安装、激活的流水线。&lt;/strong&gt; 你不用为不同种类的东西学不同的命令，也不用操心它们的存储路径和配置格式。&lt;/p&gt;
&lt;h3&gt;四个框架，一套流程&lt;/h3&gt;
&lt;p&gt;不管你用的是 Claude Code、OpenCode、Cursor 还是 Codex，AgentEnv 的命令都一样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;agentenv create --agent opencode my-env
agentenv add skill code-reviewer --source github:myorg/reviewer
agentenv lock
agentenv install
agentenv activate my-env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;框架切换只是一个 &lt;code&gt;--agent&lt;/code&gt; 参数的区别。每个框架适配器知道自己的配置路径、格式（JSON、JSONC、TOML）、以及如何正确地读写而不破坏已有配置。&lt;/p&gt;
&lt;p&gt;特别值得一提的是 &lt;strong&gt;OpenCode 适配器的 JSONC 支持&lt;/strong&gt;：OpenCode 的配置文件是带注释的 JSON（JSONC），AgentEnv 用了 &lt;code&gt;tailscale/hujson&lt;/code&gt; 来做注释保留的读写，不会像 &lt;code&gt;encoding/json&lt;/code&gt; 那样把注释全部吃光。&lt;/p&gt;
&lt;h3&gt;事务性激活，不留下残局&lt;/h3&gt;
&lt;p&gt;激活环境时，AgentEnv 做的不是简单「往配置文件里塞几行」：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;备份（backup）→ 校验（validate）→ 应用（apply）→ 失败回滚（rollback）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果校验失败或者任何步骤出错，它会自动恢复到变更前的状态。&lt;strong&gt;不会出现「配置改了一半 agent 跑不起来了」的情况。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;内容寻址存储&lt;/h3&gt;
&lt;p&gt;所有下载的包存在一个内容寻址的本地仓库里。两个环境都用到 &lt;code&gt;lint-helper@1.2.0&lt;/code&gt;？只存一份。某个包不再被任何环境引用？垃圾回收可以清理。跨文件系统的话，symlink 降级为文件复制。&lt;/p&gt;
&lt;h2&gt;快速上手&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 一行安装
curl -fsSL https://raw.githubusercontent.com/7emotions/agentenv/main/install.sh | sh

# 创建一个环境
agentenv create --agent opencode my-ai-setup

# 加几个包
agentenv add skill code-reviewer --source github:myorg/code-review-skill
agentenv add mcp filesystem \
  --source npm:@modelcontextprotocol/server-filesystem \
  --command npx --arg -y \
  --arg @modelcontextprotocol/server-filesystem \
  --arg /tmp

# 解析依赖
agentenv lock

# 下载并激活
agentenv install
agentenv activate my-ai-setup

# 搞定——你的 agent 现在有完整的运行环境了
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有命令都支持 &lt;code&gt;--json&lt;/code&gt; 输出，方便脚本化集成。&lt;/p&gt;
&lt;h2&gt;技术栈&lt;/h2&gt;
&lt;p&gt;AgentEnv 本身是一个 Go 项目（Go 1.26），单二进制发布，支持 macOS / Linux（amd64 / arm64）。核心依赖：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CLI 框架&lt;/strong&gt;：cobra（14 条命令）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;依赖解析&lt;/strong&gt;：&lt;code&gt;contriboss/pubgrub-go&lt;/code&gt;（CDCL 算法）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git 操作&lt;/strong&gt;：&lt;code&gt;go-git&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;版本约束&lt;/strong&gt;：&lt;code&gt;Masterminds/semver&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;配置格式&lt;/strong&gt;：YAML（&lt;code&gt;gopkg.in/yaml.v3&lt;/code&gt;）、TOML（&lt;code&gt;BurntSushi/toml&lt;/code&gt;）、JSONC（&lt;code&gt;tailscale/hujson&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;28 个测试文件覆盖所有包，CI 跑 &lt;code&gt;go test -race&lt;/code&gt; 在 ubuntu 和 macOS 上。&lt;/p&gt;
&lt;h2&gt;几条设计红线&lt;/h2&gt;
&lt;p&gt;做这个项目时我给自己画了几条硬线：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;零遥测&lt;/strong&gt;——不上报、不追踪、不联网发 ping。环境管理器碰触的是最敏感的本地配置文件，信任是第一位的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无插件系统&lt;/strong&gt;——所有适配器在编译时注册（&lt;code&gt;init()&lt;/code&gt;），不搞运行时动态加载。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无 Windows 支持&lt;/strong&gt;——AI 编码工具的生态集中在 macOS 和 Linux，Windows 不在范围内。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不破坏 JSONC&lt;/strong&gt;——这点值得再强调一遍：OpenCode 用户的配置文件里的注释&lt;strong&gt;一个字都不会丢&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;在生态中的位置&lt;/h2&gt;
&lt;p&gt;AI agent 环境管理 / 配置管理这个方向，已经有不少开发者在探索。这里介绍几个我关注的代表性项目——它们和 AgentEnv 解决的问题各有侧重，不存在谁更「好」，更像是在同一个大方向上填补不同的空白。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;agenv&lt;/code&gt;（combinatrix-ai）&lt;/strong&gt; 定位为「nvm for AI agents」——管理的是 agent 二进制本身的版本和 profile 隔离。你可以用 &lt;code&gt;agenv install claude&lt;/code&gt; 安装 Claude Code，再用 &lt;code&gt;agenv run&lt;/code&gt; 启动，不同 profile 之间二进制、配置、权限完全隔离。这是一个非常实用的运行时版本管理工具，和 AgentEnv 的「包依赖管理」是问题的上下游——可以先用 agenv 管理 agent 本身的版本，再用 AgentEnv 管理 agent 内部的环境依赖。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;agenv-cli&lt;/code&gt;（npm）&lt;/strong&gt; 走的是配置生成路线：从一个 &lt;code&gt;ai-workspace.json&lt;/code&gt; manifest 出发，自动检测项目类型、框架、已有配置，然后生成 Claude Code、Copilot、Codex、Cursor、Windsurf 等多个工具的配置文件。交互式的 &lt;code&gt;agenv init&lt;/code&gt; 和自动化的 &lt;code&gt;agenv generate&lt;/code&gt;，对于「想让多个工具共享一套配置规则」的场景是一个简洁高效的选择。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;agentctl&lt;/code&gt;（OrgLoop）&lt;/strong&gt; 是 agent session 监控面板，提供启动、监控、恢复、终止、对比 agent 会话的完整运维能力。它带有守护进程、目录锁、webhook 和 Prometheus 指标，做得非常专业——关注的是「同时跑多个 agent 会话怎么可靠管理」这一运维层面的问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;agentctl&lt;/code&gt;（iheanyi）&lt;/strong&gt; 的定位和 AgentEnv 最接近——跨框架配置管理，覆盖了 Claude Code、Codex、OpenCode、Cursor、Cline、Windsurf、Zed、Continue、Gemini 等 10 个框架。它可以从各框架的原生配置目录中发现已有资源（MCP 服务器、rules、commands、skills），再同步到其他框架，还提供一个交互式 TUI，使用体验很流畅。&lt;/p&gt;
&lt;p&gt;这些项目从不同角度推动着同一件事：让 AI 智能体的配置管理从「手工拷贝文件」走向「工具化、自动化」。AgentEnv 选择的角度是&lt;strong&gt;声明式包管理与依赖解析&lt;/strong&gt;——把 PubGrub 求解器、确定性锁文件、内容寻址存储这套成熟的包管理范式，带到 AI 智能体的世界。没有哪个项目是「唯一正确答案」，每个都解决了一类真实的痛点，也在互相启发。&lt;/p&gt;
&lt;h2&gt;下一步&lt;/h2&gt;
&lt;p&gt;AgentEnv 还远没有完成。接下来计划：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;依赖图可视化&lt;/strong&gt;：&lt;code&gt;agentenv graph&lt;/code&gt; 画依赖关系&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;环境对比&lt;/strong&gt;：&lt;code&gt;agentenv diff env-a env-b&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;远程锁文件共享&lt;/strong&gt;：团队里一份 &lt;code&gt;agent.lock&lt;/code&gt; 就能复现全队环境&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;更多的适配器&lt;/strong&gt;：Windsurf、Aider、Continue&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你每天在多个 AI 编码工具之间切换，或者在维护多个项目的不同配置，欢迎试试 AgentEnv。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/agentenv.git
cd agentenv
make build &amp;amp;&amp;amp; ./dist/agentenv --help
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Star、Issue、PR 都欢迎。让 AI 智能体的环境管理，走上正轨。&lt;/p&gt;
</content:encoded></item><item><title>为什么 AI Agent 总说「用 CloakBrowser」却偷偷调了 Playwright？—— 一次跨层排障与开源贡献</title><link>https://lorenzofeng.top/posts/cloakbrowser-omo-pr/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/cloakbrowser-omo-pr/</guid><description>从发现 OpenCode 会话中 cloakbrowser 反复退化为 playwright 的诡异行为，到写了一个无效的 skill，再到追踪根因至 OMO 插件的「MUST USE」死命令，最终提了一个 feat PR 把 cloakbrowser 接入框架的全过程。</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;事情开始于一个简单的测试：「测试一下 cloakbrowser 能不能用。」&lt;/p&gt;
&lt;h2&gt;一个诡异的退化&lt;/h2&gt;
&lt;p&gt;::github{repo=&quot;CloakHQ/cloakbrowser&quot;}&lt;/p&gt;
&lt;p&gt;CloakBrowser 是一个防检测浏览器——它在 Chromium 源码层面打了 48 个 C++ 补丁，能通过 reCAPTCHA v3、Cloudflare Turnstile、FingerprintJS 等几乎所有 bot 检测。npm 安装后，API 长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { launch } from &apos;cloakbrowser&apos;;
const browser = await launch();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是它 README 的第一行代码。&lt;/p&gt;
&lt;p&gt;但 OpenCode 里的 agent 写了这个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { chromium } from &apos;playwright&apos;;
const browser = await chromium.launch({
  executablePath: &apos;~/.cloakbrowser/chromium-.../chrome&apos;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;它把 cloakbrowser 当成了 Playwright 的 drop-in 二进制，而不是一个有自己 API 的包。&lt;/strong&gt; 完全跳过了 cloakbrowser 自己的 &lt;code&gt;launch()&lt;/code&gt; 函数，直接把它当原始 Chromium 喂给 Playwright。&lt;/p&gt;
&lt;h2&gt;第一步：我以为问题在 agent 身上&lt;/h2&gt;
&lt;p&gt;我的第一反应很自然：「agent 没读文档。」&lt;/p&gt;
&lt;p&gt;cloakbrowser 有 &lt;code&gt;package.json&lt;/code&gt;，有 README，有 exports 字段。只要读了其中任何一个，都不会犯这个错。问题是 agent 没读——它看到 &lt;code&gt;cloakbrowser info&lt;/code&gt; 输出一个二进制路径，就直接模式匹配到了 &lt;code&gt;chromium.launch({executablePath})&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这看起来是一个可以通过规则约束解决的问题。我花了相当的时间做了一个 &lt;code&gt;docs-before-code&lt;/code&gt; skill，按照完整的 RED-GREEN-REFACTOR 方法论：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;阶段&lt;/th&gt;
&lt;th&gt;测试&lt;/th&gt;
&lt;th&gt;结果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RED 基线&lt;/td&gt;
&lt;td&gt;agent 用 listr2 写代码（不读文档）&lt;/td&gt;
&lt;td&gt;暴露了「我知道这个库」的合理化&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GREEN 验证&lt;/td&gt;
&lt;td&gt;agent 用 vitest 程序化 API（按 skill 规则）&lt;/td&gt;
&lt;td&gt;先查 &lt;code&gt;vitest/node&lt;/code&gt; 子路径，再写代码 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REFACTOR 压力&lt;/td&gt;
&lt;td&gt;agent 用 execa v9（2 分钟限制 + 旧记忆）&lt;/td&gt;
&lt;td&gt;发现 tagged template 新语法，避免写旧 API ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这个 skill 本身是有效的。它的 Gate Function（五个步骤：IDENTIFY → LOCATE → VERIFY → CONFIRM → 写代码）能在 agent 写 &lt;code&gt;import&lt;/code&gt; 语句之前拦下它，强制它先去读 &lt;code&gt;package.json&lt;/code&gt; 的 exports 字段和 README 的第一段代码。execa v9 的压力测试证明了这一点——即使 agent 的记忆里是 &lt;code&gt;execa(&apos;cmd&apos;, [args])&lt;/code&gt; 的旧 API，skill 也强迫它先查了文档，发现了 v9 的 tagged template 新语法。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但我很快就发现，cloakbrowser 的问题完全没有被解决。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;第二步：发现 skill 完全没用&lt;/h2&gt;
&lt;p&gt;我回去翻其他会话记录。三个不同的 session 里，用户明确说了「用 cloakbrowser」，agent 全都退化到了 Playwright MCP 工具。&lt;/p&gt;
&lt;p&gt;其中一个 session 的转写特别刺眼：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User:  &quot;设置界面和你说的不一样，你用 cloakbrowser 配置&quot;
Agent: → 加载 dev-browser skill
Agent: → 加载 playwright skill
Agent: → 调用 playwright MCP 的 browser_navigate
Agent: → 完全没用 cloakbrowser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;agent 根本没走到写 &lt;code&gt;import&lt;/code&gt; 那一步。&lt;/strong&gt; 它没有写 Node.js 脚本、没有 &lt;code&gt;npm install&lt;/code&gt;、没有 &lt;code&gt;import&lt;/code&gt; 语句。它直接调了 Playwright MCP 的 &lt;code&gt;browser_navigate&lt;/code&gt;、&lt;code&gt;browser_click&lt;/code&gt; 这些现成的工具。&lt;/p&gt;
&lt;p&gt;我花了两天写的 &lt;code&gt;docs-before-code&lt;/code&gt; skill，在真实场景里一次都没触发过。不是它写错了，是它的触发条件（写 &lt;code&gt;import&lt;/code&gt; 语句）和故障场景（MCP 工具选择）根本不在同一层。&lt;/p&gt;
&lt;p&gt;这就好比你给司机装了一个「开车前检查后视镜」的提醒，但他连车都没上——他在车库门口就被导航系统指引到另一辆车上了。&lt;/p&gt;
&lt;h2&gt;第三步：追到 OMO 源码&lt;/h2&gt;
&lt;p&gt;如果 agent 是在「工具选择」阶段被拐走的，那一定有什么东西在系统 prompt 里告诉它「浏览器任务必须用 Playwright」。&lt;/p&gt;
&lt;p&gt;我开始翻系统配置。OpenCode 的 &lt;code&gt;opencode.jsonc&lt;/code&gt; 里没有 Playwright MCP 配置。&lt;code&gt;AGENTS.md&lt;/code&gt; 里也没有写死 Playwright。那这个偏好是哪里来的？&lt;/p&gt;
&lt;p&gt;答案在 OMO（Oh-My-OpenCode）插件里。在其缓存安装目录 &lt;code&gt;oh-my-openagent/dist/&lt;/code&gt; 的源码中，我找到了这段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var playwrightSkill = {
  name: &quot;playwright&quot;,
  description: &quot;MUST USE for any browser-related tasks. Browser automation via Playwright MCP ...&quot;,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&quot;MUST USE for any browser-related tasks&quot;&lt;/strong&gt; —— 这是一条注入到 agent 系统 prompt 里的死命令。当用户说「用 cloakbrowser」时，agent 的语义解析是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;cloakbrowser&quot; → 包含 &quot;browser&quot; → 这是 browser-related task → &quot;MUST USE playwright&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;系统级指令（skill description）的优先级比用户消息更高。用户的 &lt;code&gt;cloakbrowser&lt;/code&gt; 被语义压缩成了 &lt;code&gt;browser&lt;/code&gt;，然后被 MUST USE 抢占了。&lt;/p&gt;
&lt;p&gt;进一步分析确认了 OMO 有一个 &lt;code&gt;browser_automation_engine&lt;/code&gt; 配置项：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const BrowserAutomationProviderSchema = z.enum([
  &quot;playwright&quot;,
  &quot;agent-browser&quot;,
  &quot;dev-browser&quot;,
  &quot;playwright-cli&quot;,
  // 没有 &quot;cloakbrowser&quot;
])

provider: BrowserAutomationProviderSchema.default(&quot;playwright&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可选值只有四个，默认是 playwright。cloakbrowser 不在选项里。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这意味着：即使用户完全不配置任何东西，agent 看到的系统 prompt 里也会有一行 &lt;code&gt;playwright: MUST USE for any browser-related tasks&lt;/code&gt;。这不是 OMO 故意排斥 cloakbrowser——它只是还没被适配进去。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但这件事揭示了一个更深的问题：&lt;strong&gt;当框架有一个「强制指定工具」的机制时，不在名单里的工具根本就没有被正确使用的可能性。&lt;/strong&gt; 无论你写多少 skill、优化多少 prompt，agent 都会被上游的「MUST USE」指令拖走。&lt;/p&gt;
&lt;h2&gt;修框架本身&lt;/h2&gt;
&lt;p&gt;理解了根因之后，修复就很清晰了：把 cloakbrowser 加进 &lt;code&gt;browser_automation_engine&lt;/code&gt; 的 provider 选项。&lt;/p&gt;
&lt;p&gt;我 fork 了 OMO 仓库，在 &lt;code&gt;feat/cloakbrowser-provider&lt;/code&gt; 分支上改了 4 个文件，总共 +126 行：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;改动&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/config/schema/browser-automation.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;添加 &lt;code&gt;&quot;cloakbrowser&quot;&lt;/code&gt; 到 provider enum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/features/builtin-skills/skills/cloakbrowser.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;新建，119 行的 skill 模板，覆盖完整 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/features/builtin-skills/skills/index.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;导出 &lt;code&gt;cloakbrowserSkill&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/features/builtin-skills/skills.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;在 &lt;code&gt;createBuiltinSkills&lt;/code&gt; 里添加 cloakbrowser 分支&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;新增的 cloakbrowser skill 包含了完整的 API 参考：三种启动方式（&lt;code&gt;launch&lt;/code&gt;、&lt;code&gt;launchContext&lt;/code&gt;、&lt;code&gt;launchPersistentContext&lt;/code&gt;）、配置选项（headless、proxy、timezone、geoip、humanize）、CLI 工具、reCAPTCHA 优化技巧、与原生 Playwright 的关键差异。&lt;/p&gt;
&lt;p&gt;合并后，用户只需在 &lt;code&gt;oh-my-openagent.json&lt;/code&gt; 里加一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ &quot;browser_automation_engine&quot;: { &quot;provider&quot;: &quot;cloakbrowser&quot; } }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;agent 就不会再看到 &lt;code&gt;playwright: MUST USE for any browser-related tasks&lt;/code&gt;，而是看到 cloakbrowser 的 skill，知道什么时候用它、怎么用它。&lt;/p&gt;
&lt;h2&gt;PR 已提交&lt;/h2&gt;
&lt;p&gt;::github{repo=&quot;code-yeongyu/oh-my-openagent&quot;}&lt;/p&gt;
&lt;p&gt;PR #4337：&lt;a href=&quot;https://github.com/code-yeongyu/oh-my-openagent/pull/4337&quot;&gt;feat: add cloakbrowser as browser automation engine provider&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TypeScript 类型检查：✅ 零错误&lt;/li&gt;
&lt;li&gt;&lt;code&gt;browser_automation_engine&lt;/code&gt; schema 测试：✅ 3/3 通过&lt;/li&gt;
&lt;li&gt;构建：✅ 成功&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;反思&lt;/h2&gt;
&lt;p&gt;这件事最反直觉的地方在于：&lt;strong&gt;你以为是 agent 没读文档，最后发现是文档根本不在 agent 的视野里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我花了两天写了一个精心测试过的 skill，它确实能解决它设计要解决的问题（agent 写 &lt;code&gt;import&lt;/code&gt; 之前先查 API），但它对 cloakbrowser 的退化问题毫无作用——因为那个问题根本不在代码编写层。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docs-before-code&lt;/code&gt; 在工厂流水线的「组装」环节装了一个质检站，但我真正需要修的是「零件进料口」——工具选择那一层。错误不在 agent 怎么写代码，而在 agent 一开始就被给了错误的工具。&lt;/p&gt;
&lt;p&gt;这也是一条给 AI agent 框架开发者的原则：&lt;strong&gt;如果你的框架有一个「强制指定工具」的机制，确保新工具能平等地接入它。&lt;/strong&gt; 否则，你的框架就不是助推器，而是壁垒。&lt;/p&gt;
</content:encoded></item><item><title>为 CodeGraph 修复 inotify 耗尽问题：从 fs.watch 到 chokidar</title><link>https://lorenzofeng.top/posts/codegraph-inotify-fix/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/codegraph-inotify-fix/</guid><description>向 17k Star 项目 CodeGraph 提交 PR：用 chokidar + .gitignore 过滤替代 fs.watch 递归监听，将单实例 inotify watch 数从几十万降至几百，避免大仓库上内核 watch 预算耗尽</description><pubDate>Sat, 23 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;前几天我在使用 &lt;a href=&quot;https://github.com/colbymchenry/codegraph&quot;&gt;CodeGraph&lt;/a&gt; 时发现了一个问题：改完代码后 watcher 不总是能自动更新索引。深入排查后发现这与一个已知的 Linux inotify 内核限制有关，于是动手修了并 &lt;a href=&quot;https://github.com/colbymchenry/codegraph/pull/346&quot;&gt;提交了 PR&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;colbymchenry/codegraph&quot;}&lt;/p&gt;
&lt;h2&gt;背景：CodeGraph 是什么&lt;/h2&gt;
&lt;p&gt;CodeGraph 是一个用 tree-sitter 解析代码、构建语义知识图谱的工具，18 天冲到 17k Star。它为 Claude Code、Cursor、Codex CLI、OpenCode 等 AI Agent 提供毫秒级的代码符号搜索——谁调用了这个函数、这个变量定义在哪、改了这个类会影响什么——都是预建的索引直接返回。&lt;/p&gt;
&lt;p&gt;Agent 用 CodeGraph 能 &lt;strong&gt;减少 50% 以上的 token 消耗和 80% 以上的工具调用&lt;/strong&gt;。它自带文件监听器，代码改了就自动增量同步索引。&lt;/p&gt;
&lt;h2&gt;问题：改代码后索引不自动刷新&lt;/h2&gt;
&lt;p&gt;在用 OpenCode 对话时，我发现了一个现象：Agent 改了代码，然后用 CodeGraph 去查新写的函数，查不到。只有手动跑 &lt;code&gt;codegraph sync&lt;/code&gt; 后才能搜到。&lt;/p&gt;
&lt;p&gt;我开始以为是 watcher 压根没有自动同步功能，但翻源码后发现，&lt;strong&gt;watcher 是有的&lt;/strong&gt;——&lt;code&gt;src/sync/watcher.ts&lt;/code&gt; 里用 &lt;code&gt;fs.watch(root, {recursive: true})&lt;/code&gt; 监听文件变更，检测到 &lt;code&gt;.ts&lt;/code&gt; / &lt;code&gt;.py&lt;/code&gt; 等源文件改动后就触发增量 sync。&lt;/p&gt;
&lt;p&gt;那为什么我的项目里不生效？&lt;/p&gt;
&lt;h2&gt;根因：inotify 被 node_modules 吃光了&lt;/h2&gt;
&lt;p&gt;翻 GitHub Issues，找到了 &lt;a href=&quot;https://github.com/colbymchenry/codegraph/issues/276&quot;&gt;#276&lt;/a&gt;：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;fs.watch(root, {recursive: true})&lt;/code&gt; 在 Linux 上为每个目录注册一个 inotify watch —— 包括 &lt;code&gt;node_modules/&lt;/code&gt;、&lt;code&gt;.git/&lt;/code&gt;、&lt;code&gt;.next/&lt;/code&gt;、&lt;code&gt;dist/&lt;/code&gt; 等目录。过滤逻辑写在回调里，此时 watch 早已注册完成。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Linux 内核默认允许一个用户最多持有 &lt;strong&gt;65536&lt;/strong&gt; 个 inotify watch。而 &lt;code&gt;node_modules/&lt;/code&gt; 下面动辄几千个小包目录，每个包里又有自己的 &lt;code&gt;node_modules/&lt;/code&gt;——一个中等规模的 monorepo 很容易就跑出几万甚至十几万个目录。&lt;/p&gt;
&lt;p&gt;CodeGraph 的递归 watcher 会为每个目录注册 watch，哪怕回调里会把这些目录的事件扔掉。结果是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单个 &lt;code&gt;codegraph serve --mcp&lt;/code&gt; 实例占掉大半的 inotify 预算&lt;/li&gt;
&lt;li&gt;同一仓库开两个 agent 会话 → 直接触顶&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;其他工具报错&lt;/strong&gt;：&lt;code&gt;ENOSPC: System limit for number of file watchers reached&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;更糟的是，PR #286 发现 agent 会话结束时子进程可能变成孤儿继续跑，积压的僵尸进程带着全部 watch 赖着不走&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;根本原因是 &lt;strong&gt;&quot;先注册，再过滤&quot;&lt;/strong&gt;。正确的做法应该是 &lt;strong&gt;&quot;先过滤，再注册&quot;&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;方案：用 chokidar 替代 fs.watch&lt;/h2&gt;
&lt;p&gt;Issue 里提出了两个修复方向：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;遍历树，手动过滤目录，对通过过滤的目录注册非递归 &lt;code&gt;fs.watch&lt;/code&gt; —— 等于自己写一个 mini chokidar&lt;/li&gt;
&lt;li&gt;直接用 chokidar，它的 &lt;code&gt;ignored&lt;/code&gt; 回调在注册 watch &lt;strong&gt;之前&lt;/strong&gt;过滤&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我选了方案 2。chokidar 是一个周下载 4000 万+ 的纯 JS 库，早把目录遍历、过滤、watch 管理、跨平台兼容这些 edge case 踩平了。而且它是纯 JavaScript，零 native 编译——不破坏 CodeGraph &quot;self-contained，零编译&quot; 的卖点。&lt;/p&gt;
&lt;p&gt;核心改动不到 100 行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 之前：先注册所有 watch，回调里再过滤（太晚）
this.watcher = fs.watch(this.projectRoot, { recursive: true }, (_eventType, filename) =&amp;gt; {
    if (!isSourceFile(filename)) return;  // 过滤在注册之后
});

// 之后：用 chokidar 的 ignored 回调，先过滤再注册
this.watcher = chokidar.watch(this.projectRoot, {
    ignored: (testPath) =&amp;gt; {
        // .gitignore 规则 + .codegraph/ + .git/
        // 返回 true 的路径不会被注册 inotify watch
    },
});

this.watcher.on(&apos;all&apos;, (_event, filePath) =&amp;gt; {
    if (!isSourceFile(filePath)) return; // 防御性过滤仍然保留
    // ... 触发 debounced sync
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;过滤逻辑：从项目根目录向上遍历，加载所有 &lt;code&gt;.gitignore&lt;/code&gt; 文件（跟 git 的行为一致），用已有的 &lt;code&gt;ignore&lt;/code&gt; npm 包做匹配，同时硬编码排除 &lt;code&gt;.codegraph/&lt;/code&gt; 和 &lt;code&gt;.git/&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;改动量：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;改动&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;package.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;加 &lt;code&gt;chokidar&lt;/code&gt; 依赖（v4，CommonJS 兼容）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/sync/watcher.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;+90 行：&lt;code&gt;.gitignore&lt;/code&gt; 加载 + &lt;code&gt;ignored&lt;/code&gt; 回调 + 事件处理重构&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对现有功能无影响：WSL2 &lt;code&gt;/mnt/*&lt;/code&gt; 自动禁用、&lt;code&gt;CODEGRAPH_NO_WATCH&lt;/code&gt; 环境变量、git hook 替代方案全部保持不变。&lt;/p&gt;
&lt;h2&gt;测试&lt;/h2&gt;
&lt;h3&gt;单元测试&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vitest __tests__/watcher.test.ts      → 9/9 ✅
vitest __tests__/watch-policy.test.ts → 8/8 ✅
tsc --noEmit                          → clean ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有 watcher 单元测试原封不动通过——chokidar 的事件模型与 &lt;code&gt;fs.watch&lt;/code&gt; 兼容，debounce 逻辑、过滤逻辑、lifecycle 全部保持一致。sync 测试因测试环境缺少 &lt;code&gt;node:sqlite&lt;/code&gt; 而失败（已有问题）。&lt;/p&gt;
&lt;h3&gt;inotify watch 实测&lt;/h3&gt;
&lt;h4&gt;策略&lt;/h4&gt;
&lt;p&gt;不能靠目录遍历估算——事后对比发现，&lt;code&gt;fs.watch({recursive: true})&lt;/code&gt; 注册的 watch 数远超肉眼可见的目录数（博客项目目录树 ~9,000 个，实测 watch 数 ~48,000，偏差 5 倍）。必须从内核的真实数据源读取。&lt;/p&gt;
&lt;p&gt;测试方法：在同一进程内先后跑旧方案和新方案，各自从 &lt;code&gt;/proc/self/fdinfo/&lt;/code&gt; 读取内核分配的 inotify watch 计数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. fs.watch(root, {recursive: true})
2. 等待 3 秒 → 内核完成所有目录的 inotify 注册
3. 遍历 /proc/self/fd/* → 找到类型为 anon_inode:inotify 的 fd
4. 读对应 /proc/self/fdinfo/{fd} → 统计 inotify wd: 行数
5. → OLD 值

6. 关闭旧 watcher
7. 加载 .gitignore 规则链
8. chokidar.watch(root, { ignored: gitignoreFilter })
9. 等待 &apos;ready&apos; 事件 → 确保 watch 全部注册完毕
10. 再次读 /proc/self/fdinfo/*
11. → NEW 值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为什么不用 &lt;code&gt;lsof&lt;/code&gt;、&lt;code&gt;pgrep&lt;/code&gt;、spawn 子进程？&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方法&lt;/th&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lsof -p PID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不显示单个 inotify 实例内的 watch 条目数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spawn 子进程 + 父进程读 &lt;code&gt;/proc/{child}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stdout 管道捕获不可靠，子进程可能先于测量退出&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;目录遍历计数&lt;/td&gt;
&lt;td&gt;严重低估——&lt;code&gt;fs.watch&lt;/code&gt; 的内核行为比&quot;每个目录一个 watch&quot;复杂得多&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;结果&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;中等项目&lt;/strong&gt;（博客，pnpm + Astro + Tailwind，~9,000 个目录）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;修复前&lt;/th&gt;
&lt;th&gt;修复后&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inotify watch 数&lt;/td&gt;
&lt;td&gt;48,368&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;节省&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;48,193（99.6%）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;小型项目&lt;/strong&gt;（codegraph 仓库本身，~250 个目录）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;修复前&lt;/th&gt;
&lt;th&gt;修复后&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inotify watch 数&lt;/td&gt;
&lt;td&gt;2,045&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;节省&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1,836（90%）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;测试环境内核限额：&lt;strong&gt;65,536&lt;/strong&gt;。旧方案下，博客项目一个 agent 会话就吃掉限额的 74%——开两个会话直接触顶。issue #276 reporter 的 monorepo（~20 万文件）实测每实例 ~44 万个 watch，单项就把预算撑爆 6 次以上。&lt;/p&gt;
&lt;h4&gt;局限性&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;只测了两次，没算方差。&lt;/strong&gt; 单次运行。如果 &lt;code&gt;fs.watch&lt;/code&gt; 的异步注册在 3 秒内没走完，OLD 值可能偏低。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只有两个项目，规模跨度有限。&lt;/strong&gt; 最大的也就 ~9,000 个目录、48k watch，远未达到 issue #276 reporter 的 44 万级别。更大规模项目上 OLD/NEW 的绝对差距会更大，但比例不一定线性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每个项目只跑了一轮对比。&lt;/strong&gt; 没有测多次取均值排除抖动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没测并发。&lt;/strong&gt; 单实例测试，没有验证两个 watcher 同时运行时是否有额外的资源竞争（虽然理论上不会，但没有实测）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没测 macOS / Windows。&lt;/strong&gt; 只测了 Linux。chokidar 在其他平台上使用 FSEvents / ReadDirectoryChangesW，行为可能不同——但对于这个 PR 来说，这两个平台不是问题域。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;修复效果完全依赖 &lt;code&gt;.gitignore&lt;/code&gt; 质量。&lt;/strong&gt; 如果项目没写 &lt;code&gt;node_modules/&lt;/code&gt; 到 &lt;code&gt;.gitignore&lt;/code&gt;，修复后 watch 数几乎不变。这不是 bug，但需要明确。&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;对维护者的建议&lt;/h4&gt;
&lt;p&gt;如果上游合并这个 PR，建议在 release notes 或 README 中补充一句提醒：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;On Linux, the file watcher now respects &lt;code&gt;.gitignore&lt;/code&gt; to avoid consuming inotify watches on excluded directories (e.g. &lt;code&gt;node_modules/&lt;/code&gt;, &lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;). Make sure your &lt;code&gt;.gitignore&lt;/code&gt; covers these directories, or explicitly add &lt;code&gt;CODEGRAPH_NO_WATCH=1&lt;/code&gt; if the watcher still causes issues on very large repos.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;同时，如果合并后有人报告 watch 数依然很高，第一反应应该是检查他们的 &lt;code&gt;.gitignore&lt;/code&gt; 是否包含了 &lt;code&gt;node_modules/&lt;/code&gt;、&lt;code&gt;dist/&lt;/code&gt;、&lt;code&gt;build/&lt;/code&gt;、&lt;code&gt;.next/&lt;/code&gt; 等常见构建输出目录。&lt;/p&gt;
&lt;h2&gt;提 PR&lt;/h2&gt;
&lt;p&gt;改动确认无误后，fork 了上游仓库，推送分支，提交了 PR：&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;colbymchenry/codegraph&quot;}&lt;/p&gt;
&lt;p&gt;PR #346 已链接到 issue #276，合并后自动关闭。CodeGraph 仓库目前有 60+ 个 open PR，维护者应该比较忙，等待 review 中。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;从发现到修复的全过程&lt;/strong&gt;：用户报 issue → 看代码怀疑 watcher 不工作 → 查 issues 发现是 inotify 预算问题 → 评估修复方案 → 选 chokidar → 改代码 → 跑测试 → 提 PR。整个过程下来，对 Linux inotify 机制、chokidar 的工作原理、以及 CodeGraph 的 watcher 架构有了更深的理解。&lt;/p&gt;
&lt;p&gt;开源就是这样——你用一个工具，发现它的 bug，然后顺手把它修了。17k Star 的项目也不例外。&lt;/p&gt;
</content:encoded></item><item><title>自建邮件服务器：Stalwart + 阿里云绕过 25 端口限制</title><link>https://lorenzofeng.top/posts/email-server/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/email-server/</guid><description>在阿里云 ECS 上使用 Docker 搭建 Stalwart 邮件服务器，通过阿里云邮件推送绕过出站 25 端口封锁，实现完整收发邮件。</description><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我把自己搭建邮件服务器的过程整理成了一个 &lt;a href=&quot;https://github.com/7emotions/email-server&quot;&gt;GitHub 仓库&lt;/a&gt;，包含完整的 Docker Compose 配置、Caddy 反向代理、部署前检查脚本和部署后验证脚本。本文是对整个方案的说明。&lt;/p&gt;
&lt;h2&gt;背景&lt;/h2&gt;
&lt;p&gt;在云服务器上自建邮件服务，最大的障碍不是安装配置，而是&lt;strong&gt;出站 25 端口被封锁&lt;/strong&gt;。阿里云、腾讯云等国内厂商默认封锁 SMTP 出站端口，意味着服务器可以接收邮件，但无法直接投递到外部邮箱。&lt;/p&gt;
&lt;p&gt;解决方案：通过&lt;strong&gt;阿里云邮件推送（DirectMail）&lt;/strong&gt; 作为 SMTP 中继 — 邮件先提交到阿里云的 465 端口，再由阿里云代为投递到目标服务器。这也是生产环境的标准做法。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/email-server&quot;}&lt;/p&gt;
&lt;h2&gt;为什么选 Stalwart？&lt;/h2&gt;
&lt;p&gt;市面上常见的自建邮局方案有 Mailu、Mailcow、iRedMail 等，它们功能完善但组件繁多（动辄 8-10 个容器），对 2GB 以下内存的服务器不友好。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://stalw.art&quot;&gt;Stalwart Mail Server&lt;/a&gt; 是一个用 Rust 从头编写的现代化邮件服务器，&lt;strong&gt;单个容器&lt;/strong&gt;同时提供 SMTP、IMAP、JMAP 和 Web 管理面板，内存占用约 300MB。对比 Mailu 需要 9 个容器（Redis、Postfix、Dovecot、ClamAV、Rspamd 等），这在资源有限的 VPS 上是巨大的优势。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;容器数&lt;/th&gt;
&lt;th&gt;内存占用&lt;/th&gt;
&lt;th&gt;管理面板&lt;/th&gt;
&lt;th&gt;JMAP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mailu&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;~1.5 GB&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailcow&lt;/td&gt;
&lt;td&gt;~15&lt;/td&gt;
&lt;td&gt;~4 GB&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stalwart&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~300 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;项目结构&lt;/h2&gt;
&lt;p&gt;仓库包含以下内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;email-server/
├── compose.yaml           # Stalwart Docker Compose 配置
├── caddy/
│   └── Caddyfile.mail     # Caddy 反向代理配置
├── scripts/
│   ├── preflight.sh       # 部署前 8 项环境检查
│   └── verify.sh          # 部署后 14 项验证
└── README.md              # 完整部署指南
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;compose.yaml — 极简配置&lt;/h3&gt;
&lt;p&gt;核心的 &lt;code&gt;compose.yaml&lt;/code&gt; 仅 31 行，一个 service、两个 volume、几行日志配置，干净利落。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Stalwart Email Server — Docker Compose
# Domain: &amp;lt;YOUR_DOMAIN&amp;gt; | Image: ghcr.io/stalwartlabs/stalwart:v0.16
#
# Ports:
#   Mail: 25 (SMTP), 465 (SMTPS), 587 (Submission), 993 (IMAPS), 143 (IMAP)
#   Admin: 127.0.0.1:8080 (internal, behind Caddy reverse proxy)

services:
  stalwart:
    image: ghcr.io/stalwartlabs/stalwart:v0.16
    container_name: stalwart
    restart: unless-stopped
    ports:
      - &quot;25:25&quot;
      - &quot;465:465&quot;
      - &quot;587:587&quot;
      - &quot;143:143&quot;
      - &quot;993:993&quot;
      - &quot;127.0.0.1:8080:8080&quot;
    volumes:
      - stalwart-data:/opt/stalwart
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;
    environment:
      - TZ=Asia/Shanghai

volumes:
  stalwart-data:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署只需要三条命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker volume create email-server_stalwart-data
docker compose up -d
# 浏览器打开 http://&amp;lt;服务器IP&amp;gt;:8080/admin 完成初始配置
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;preflight.sh — 部署前环境检查&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;docker compose up -d&lt;/code&gt; 之前运行，自动检测 8 项环境前提：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Docker daemon 是否运行&lt;/li&gt;
&lt;li&gt;Docker Compose 是否安装&lt;/li&gt;
&lt;li&gt;端口 25/465/587/993/8080 是否被占用&lt;/li&gt;
&lt;li&gt;iptables DOCKER 链是否正常&lt;/li&gt;
&lt;li&gt;内存 ≥ 1 GiB&lt;/li&gt;
&lt;li&gt;磁盘可用 ≥ 20 GB&lt;/li&gt;
&lt;li&gt;Swap 是否配置&lt;/li&gt;
&lt;li&gt;DNS 解析是否正常&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;核心检查逻辑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Check 1: Docker Daemon
docker info &amp;gt;/dev/null 2&amp;gt;&amp;amp;1

# Check 3: Required Ports Free (25, 465, 587, 993, 8080)
for port in 25 465 587 993 8080; do
    ss -tlnp &quot;sport = :$port&quot; | grep -q LISTEN &amp;amp;&amp;amp; occupied=true
done

# Check 5: RAM &amp;gt;= 1.0 GiB
total_mib=$(free -m | awk &apos;/Mem:/ {print $2}&apos;)
[ &quot;$total_mib&quot; -ge 1024 ]

# Check 6: Disk Space &amp;gt;= 20 GB Free
free_gb=$(df -BG / | awk &apos;NR==2 {print $4}&apos; | sed &apos;s/G//&apos;)
[ &quot;$free_gb&quot; -ge 20 ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;全部通过才建议继续部署，避免半路踩坑。&lt;/p&gt;
&lt;h3&gt;verify.sh — 部署后全面验证&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;docker compose up -d&lt;/code&gt; 完成后运行，14 项检查覆盖容器健康、端口连通性、服务响应和 DNS 解析：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有容器是否正常运行&lt;/li&gt;
&lt;li&gt;是否有容器在重启循环&lt;/li&gt;
&lt;li&gt;SMTP/IMAP 端口是否可达&lt;/li&gt;
&lt;li&gt;SMTP banner 是否返回&lt;/li&gt;
&lt;li&gt;DNS 解析是否正常&lt;/li&gt;
&lt;li&gt;磁盘使用率 &amp;lt; 80%&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;全部通过意味着邮件服务器部署成功。&lt;/p&gt;
&lt;h2&gt;架构总览&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;flowchart TB
    subgraph Internet[&quot;🌐 Internet&quot;]
        Incoming[&quot;外部邮件服务器&quot;]
        Recipient[&quot;收件人邮箱&amp;lt;br/&amp;gt;QQ / Gmail / Outlook&quot;]
    end

    subgraph Aliyun[&quot;☁️ 阿里云 ECS&quot;]
        subgraph Proxy[&quot;反向代理&quot;]
            Caddy[&quot;Caddy&amp;lt;br/&amp;gt;:80 / :443&quot;]
        end

        subgraph MailServer[&quot;Stalwart Mail Server&quot;]
            SMTP[&quot;SMTP&amp;lt;br/&amp;gt;:25 :465 :587&quot;]
            IMAP[&quot;IMAP&amp;lt;br/&amp;gt;:143 :993&quot;]
            Admin[&quot;Admin Panel&amp;lt;br/&amp;gt;:8080 内网&quot;]
            Storage[(&quot;RocksDB&amp;lt;br/&amp;gt;存储&quot;)]
        end
    end

    subgraph Relay[&quot;📡 阿里云 DirectMail&quot;]
        DM[&quot;SMTP 中继&amp;lt;br/&amp;gt;smtpdm.aliyun.com:465&quot;]
    end

    User[&quot;👤 管理员&amp;lt;br/&amp;gt;浏览器&quot;] --&amp;gt;|&quot;HTTPS&quot;| Caddy
    Caddy --&amp;gt;|&quot;反向代理&quot;| Admin
    Client[&quot;📱 邮件客户端&quot;] --&amp;gt;|&quot;SMTP/IMAP&quot;| SMTP
    Client --&amp;gt;|&quot;SMTP/IMAP&quot;| IMAP

    Incoming --&amp;gt;|&quot;📥 收信 :25&quot;| SMTP
    SMTP --&amp;gt;|&quot;本地投递&quot;| Storage

    SMTP --&amp;gt;|&quot;📤 外发 :465&quot;| DM
    DM --&amp;gt;|&quot;阿里云代投&quot;| Recipient

    style Aliyun fill:#f0f9ff,stroke:#0284c7
    style MailServer fill:#ecfdf5,stroke:#059669
    style Relay fill:#fef3c7,stroke:#d97706
    style Proxy fill:#fdf2f8,stroke:#db2777
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;关键配置点&lt;/h2&gt;
&lt;h3&gt;1. DNS 记录&lt;/h3&gt;
&lt;p&gt;部署前需要在域名 DNS 配置 6 条记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MX&lt;/strong&gt; — 收信路由，指向 &lt;code&gt;mail.&amp;lt;DOMAIN&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SPF&lt;/strong&gt; — 声明阿里云和自有服务器可代表域名发信&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DKIM&lt;/strong&gt; — 邮件签名，防止伪造（从 Stalwart 管理面板获取公钥）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DMARC&lt;/strong&gt; — 收信方验证失败时的处理策略&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SRV&lt;/strong&gt; — 客户端自动发现（可选）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 阿里云 DirectMail 中继&lt;/h3&gt;
&lt;p&gt;在 Stalwart 的 Outbound Settings 中配置 Routing Strategy：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IF  is_local_domain(rcpt_domain)  →  本地投递
ELSE                               →  aliyun 中继外发
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样发往自己域名的邮件本地存储，发往外部的邮件走阿里云中继，互不干扰。&lt;/p&gt;
&lt;h3&gt;3. SSL 证书&lt;/h3&gt;
&lt;p&gt;Stalwart 支持挂载 certbot 证书。需要注意的是 Let&apos;s Encrypt 的 &lt;code&gt;live&lt;/code&gt; 目录只有 root 可遍历，需要额外执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chmod o+x /etc/letsencrypt/live /etc/letsencrypt/archive
chmod 644 /etc/letsencrypt/archive/&amp;lt;DOMAIN&amp;gt;/privkey*.pem
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;客户端使用&lt;/h2&gt;
&lt;p&gt;配置完成后，可以用任意邮件客户端连接：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;协议&lt;/th&gt;
&lt;th&gt;服务器&lt;/th&gt;
&lt;th&gt;端口&lt;/th&gt;
&lt;th&gt;加密&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SMTP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mail.&amp;lt;DOMAIN&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;465&lt;/td&gt;
&lt;td&gt;SSL/TLS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMAP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mail.&amp;lt;DOMAIN&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;993&lt;/td&gt;
&lt;td&gt;SSL/TLS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;用户名填完整邮箱地址，密码填 Stalwart 中设置的邮箱密码。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这个仓库把「自建邮件服务器」这件事做成了一个可复现的方案：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;一条 compose up&lt;/strong&gt; 启动服务&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;preflight.sh&lt;/strong&gt; 帮你检查环境，避免部署到一半才发现问题&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;verify.sh&lt;/strong&gt; 帮你验证部署结果，不用自己逐项排查&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;README&lt;/strong&gt; 记录每一步操作，包括常见的坑和解决方案&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;300MB 内存跑一个完整邮件服务器，比 Mailu/Mailcow 轻量得多，适合个人 VPS 自用。&lt;/p&gt;
&lt;p&gt;仓库地址：&lt;a href=&quot;https://github.com/7emotions/email-server&quot;&gt;github.com/7emotions/email-server&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>给 AI Agent 装上「嘴巴」和「耳朵」—— 让 OpenCode 真实打电话</title><link>https://lorenzofeng.top/posts/phone-agent-mcp/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/phone-agent-mcp/</guid><description>Phone Agent MCP 开发：通过 ADB + 蓝牙 HSP 让 AI Agent 真正拨打电话、自主对话，edge-tts + whisper + DeepSeek 实现端到端语音交互</description><pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;背景&lt;/h1&gt;
&lt;p&gt;在使用 OpenCode 这样的 AI Agent 平台时，Agent 可以写代码、读文件、搜文档、部署服务，但有一个根本的能力空白：&lt;strong&gt;它不能打电话&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;想象这些场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Agent 帮你完成了代码部署，需要通知王经理 —— 你只能自己拿起手机&lt;/li&gt;
&lt;li&gt;Agent 从数据库里找到了一批欠费用户，需要逐一确认 —— 你手动拨号&lt;/li&gt;
&lt;li&gt;Agent 检测到服务器宕机，想通知运维 —— 它只能发文字消息&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Agent 不缺信息，缺的是&lt;strong&gt;触达真人的通道&lt;/strong&gt;。电话仍然是最高优先级的通知方式 —— 微信可能静音，邮件可能淹没，但电话铃响，对方一定会接。&lt;/p&gt;
&lt;p&gt;于是我写了&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/phone-agent&quot;}&lt;/p&gt;
&lt;h1&gt;功能&lt;/h1&gt;
&lt;p&gt;Phone Agent 是一个 MCP Server，跑在连接了 Android 手机的 Linux 机器上，向 OpenCode Agent 暴露 7 个工具：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCP Tool&lt;/th&gt;
&lt;th&gt;功能&lt;/th&gt;
&lt;th&gt;典型场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_dial&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;拨号 + 预生成开场白 TTS&lt;/td&gt;
&lt;td&gt;任何电话的第一步&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_converse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;多轮自主对话（LLM 驱动）&lt;/td&gt;
&lt;td&gt;确认出席、通知延期、收集信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_ask&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;单轮：问 → 录 → 识别 → 提取&lt;/td&gt;
&lt;td&gt;简单的是/否问题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_speak&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;单向 TTS 播放&lt;/td&gt;
&lt;td&gt;纯通知，不需要回复&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_check&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查询通话状态&lt;/td&gt;
&lt;td&gt;拨号前确认、挂断后确认&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_hangup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;挂断&lt;/td&gt;
&lt;td&gt;结束时调用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_filler&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;播放预生成的垫话&lt;/td&gt;
&lt;td&gt;桥接 TTS 生成延迟&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Agent 只需要组合这些工具就可以完成完整的通话任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;phone_dial(number, opening=&quot;您好，我是XX公司的AI助手...&quot;)
  → 拨号 + TTS 后台生成，接通瞬间播放

phone_converse(goal=&quot;确认明天评审会出席&quot;, skip_opening=true)
  → LLM 自主对话：录 → 识别 → 决策 → TTS → 回复 → 循环

phone_hangup()
  → 挂断，Agent 总结对话结果
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;架构设计&lt;/h1&gt;
&lt;h2&gt;整体架构&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                  OpenCode Agent                       │
│  phone_dial / hangup / check / speak / ask / converse │
└─────────────────────┬────────────────────────────────┘
                      │ MCP stdio (JSON-RPC)
┌─────────────────────▼────────────────────────────────┐
│              phone_call_mcp.py (688行)                │
│  ├─ 拨号调度    → ADB am start CALL                  │
│  ├─ 对话引擎    → DeepSeek API 方向盘                 │
│  ├─ 语音合成    → edge-tts (云端) / espeak-ng (本地)   │
│  ├─ 语音识别    → faster-whisper tiny                │
│  ├─ 录音控制    → WebRTC VAD + parecord               │
│  ├─ 回声管理    → 5次重试清除 loopback                │
│  └─ 蓝牙管理    → HSP 自动重连                        │
└──────┬──────────────────────────┬────────────────────┘
       │                          │
   ┌───▼────┐              ┌──────▼──────┐
   │  ADB   │              │  蓝牙 HSP    │
   │ (拨号) │              │ paplay 上行  │
   └────────┘              │ parecord 下行│
                           └─────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;为什么是 MCP？&lt;/h2&gt;
&lt;p&gt;OpenCode 本身没有「打电话」的能力，但它有 MCP 扩展机制。我把整个电话能力包装成一个 MCP Server：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Agent 不需要知道蓝牙、ADB、PulseAudio 的存在&lt;/strong&gt; —— 它只需要调用工具&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对话决策在远端 LLM 完成&lt;/strong&gt; —— Agent 只负责编排，不参与每轮决定&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skill 提供使用指南&lt;/strong&gt; —— &lt;code&gt;phone-call&lt;/code&gt; Skill 告诉 Agent 什么时候用哪个工具、怎么组合&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;双引擎设计：API + 本地各司其职&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;对话决策 (方向盘)  → DeepSeek API  → 约 1 秒延迟，语义理解强
简单提取 (辅助)    → 本地 1.5B LM → 更快的结构化提取
TTS               → edge-tts      → 2-5 秒，后台并行生成
ASR               → whisper tiny  → 1-3 秒，速度优先
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;为什么不用纯本地？&lt;/strong&gt; 1.5B 的本地模型做多轮对话决策不稳定，容易陷入重复提问。DeepSeek API 做方向盘，本地模型做简单提取，各做自己擅长的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么用 whisper tiny 而不是 small？&lt;/strong&gt; 实测 tiny 转写日常中文对话准确率 90% 以上，但比 small 快 3-5 倍。电话场景需要「够用就好」的转写速度，而不是精准字幕。&lt;/p&gt;
&lt;h1&gt;关键技术&lt;/h1&gt;
&lt;h2&gt;四层停止机制&lt;/h2&gt;
&lt;p&gt;多轮对话最大的坑是「不知道什么时候停」。Phone Agent 实现了四层防御：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;机制&lt;/th&gt;
&lt;th&gt;触发条件&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. 模型 &lt;code&gt;done&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;LLM 判断对话完成&lt;/td&gt;
&lt;td&gt;信息收集完毕 / 对方拒绝 / 对话结束&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. 关键词&lt;/td&gt;
&lt;td&gt;检测推脱/拒绝语&lt;/td&gt;
&lt;td&gt;&quot;帮你记&quot;、&quot;打错&quot;、&quot;稍后联系&quot;、&quot;尽快&quot; 等 25 个以上关键词&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. 去重&lt;/td&gt;
&lt;td&gt;连续两轮相同回复&lt;/td&gt;
&lt;td&gt;&quot;喂？&quot; → &quot;喂？&quot;（对方不配合）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. max_turns&lt;/td&gt;
&lt;td&gt;硬上限&lt;/td&gt;
&lt;td&gt;安全网，防止死循环&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;经过 184 条多场景模拟对话测试，停止准确率达到 &lt;strong&gt;98%&lt;/strong&gt;。模型 &lt;code&gt;done&lt;/code&gt; 是第一道线，关键词是安全网 —— 模型有时会被&quot;我帮你记一下&quot;这类委婉拒绝骗过去，关键词层补上这个缺口。&lt;/p&gt;
&lt;h2&gt;两段式开场：零延迟通话&lt;/h2&gt;
&lt;p&gt;一个容易被忽略但体验差距巨大的细节：&lt;strong&gt;TTS 生成需要 2-5 秒&lt;/strong&gt;。如果接通后才开始生成 TTS，对方会听到 2-5 秒的沉默，以为是骚扰电话直接挂掉。&lt;/p&gt;
&lt;p&gt;解决方案：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;phone_dial(number, opening=&quot;您好，我是...&quot;)
    │
    ├── 拨号中 (ringing) ───→ 后台生成 opening TTS
    │
    └── 对方接听 ───→ 检测 state=ACTIVE ───→ 立即播放 TTS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;开场白在拨号振铃期间后台生成，对方接通的瞬间就听到声音 —— 就像真人打电话一样自然。后续对话通过 &lt;code&gt;phone_converse(skip_opening=true)&lt;/code&gt; 无缝衔接。&lt;/p&gt;
&lt;h2&gt;TTS 间隙填补&lt;/h2&gt;
&lt;p&gt;录音结束后，LLM 决策 + TTS 生成需要 3-8 秒。如果对方在这期间听不到任何声音，会以为断线。&lt;/p&gt;
&lt;p&gt;解决：录音结束 2 秒后，如果 TTS 还没生成完，自动播放预录的垫话「请稍等，让我思考一下」。如果 TTS 在垫话期间完成，等垫话播完再切入 TTS —— 不抢话、不重叠、不断线。&lt;/p&gt;
&lt;p&gt;第一轮永远不播垫话（对方刚接电话，垫话显得突兀）。&lt;/p&gt;
&lt;h2&gt;「不知道就说不知道」&lt;/h2&gt;
&lt;p&gt;LLM 最大的问题之一是&lt;strong&gt;幻觉&lt;/strong&gt;——被问到不知道的信息时会编造答案。在电话场景里这尤其危险。&lt;/p&gt;
&lt;p&gt;Phone Agent 在 System Prompt 里明确要求：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;遇到你不知道的信息，诚实说&quot;这个我不确定，我确认后再回复您&quot;，返回 done，reason 写 &quot;callback: 需要确认XXX后再回电&quot;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Agent 拿到 &lt;code&gt;done_reason: &quot;callback:...&quot;&lt;/code&gt; 后会告知用户需要查证，然后带着确认后的信息重拨。&lt;/p&gt;
&lt;h1&gt;如何使用&lt;/h1&gt;
&lt;h2&gt;1. 硬件准备&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;一台 Linux 电脑&lt;/strong&gt;（蓝牙 + USB）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一台 Android 手机&lt;/strong&gt;（已 root，已测试 Xiaomi 22041216C / Android 14 / MTK Dimensity 8100）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;USB 数据线&lt;/strong&gt;连接电脑和手机&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 蓝牙配对 (HSP)&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;bluetoothctl pair F8:AB:82:92:08:76
bluetoothctl trust F8:AB:82:92:08:76
pactl list cards short | grep bluez  # 确认识别
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HSP（Headset Profile）是关键——它能提供双向 8kHz 语音通路。A2DP 只能单向放音乐，不能录音。&lt;/p&gt;
&lt;h2&gt;3. 安装依赖&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install mcp edge-tts webrtcvad
apt install pulseaudio pulseaudio-module-bluetooth ffmpeg
pip install faster-whisper  # ASR
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 生成垫话音频&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python3 gen_fillers.py
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 配置 MCP Server&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/phone-agent.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;opencode.jsonc&lt;/code&gt; 中添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;phone-call&quot;: {
  &quot;type&quot;: &quot;local&quot;,
  &quot;command&quot;: [&quot;python3&quot;, &quot;/path/to/phone_call_mcp.py&quot;],
  &quot;enabled&quot;: true,
  &quot;timeout&quot;: 300000,
  &quot;environment&quot;: {
    &quot;PHONE_CONVERSE_BACKEND&quot;: &quot;api&quot;,
    &quot;PHONE_TTS_BACKEND&quot;: &quot;edge&quot;,
    &quot;PHONE_LLM_URL&quot;: &quot;https://api.deepseek.com/chat/completions&quot;,
    &quot;PHONE_LLM_KEY&quot;: &quot;sk-xxx&quot;,
    &quot;PHONE_LLM_MODEL&quot;: &quot;deepseek-chat&quot;,
    &quot;PHONE_LLM_CONTEXT&quot;: &quot;你是XX公司的AI助手。&quot;,
    &quot;PHONE_BT_MAC&quot;: &quot;F8:AB:82:92:08:76&quot;,
    &quot;PHONE_BT_CARD&quot;: &quot;bluez_card.F8_AB_82_92_08_76&quot;,
    &quot;PHONE_BT_SINK&quot;: &quot;bluez_sink.F8_AB_82_92_08_76.headset_audio_gateway&quot;,
    &quot;PHONE_BT_SOURCE&quot;: &quot;bluez_source.F8_AB_82_92_08_76.headset_audio_gateway&quot;,
    &quot;PHONE_ADB&quot;: &quot;/path/to/adb&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 安装 Agent Skill&lt;/h2&gt;
&lt;p&gt;将仓库里的 &lt;code&gt;skill-phone-call.md&lt;/code&gt; 复制到 &lt;code&gt;~/.agents/skills/phone-call/SKILL.md&lt;/code&gt;，重启 OpenCode。&lt;/p&gt;
&lt;h2&gt;7. 使用&lt;/h2&gt;
&lt;p&gt;配置完成后，Agent 就获得了打电话能力。对它说：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;帮我打电话给 138xxxx，确认他明天下午能否参加评审会&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Agent 会自动：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;调用 &lt;code&gt;phone_dial&lt;/code&gt; 拨号，附带预生成的「你好，我是 XX 公司 AI 助手...」开场白&lt;/li&gt;
&lt;li&gt;调用 &lt;code&gt;phone_converse&lt;/code&gt; 进行多轮对话&lt;/li&gt;
&lt;li&gt;在对话完成后挂断&lt;/li&gt;
&lt;li&gt;从返回的 &lt;code&gt;transcripts&lt;/code&gt; 中提取信息，用自然语言回复你&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;对话演示&lt;/h1&gt;
&lt;p&gt;一段真实通话记录（对方为某公司客服）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Agent: 您好，我是南京青赋驭境的AI助手，想跟您确认一下
       明天下午的评审会您这边能参加吗？

Caller: 现上评审会是吧？请问具体是哪个部门或者项目的会议呢？

Agent: 是明天下午的线上评审会，具体是南京青赋驭境的项目评审会。

Caller: 嗯，收到了，我会尽快帮您确认一下是否能参加的。

→ 触发停止（关键词：&quot;尽快&quot;）— 对方表达了帮忙意愿但未承诺
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另一段成功回电流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Agent: 您好，刚才关于XXX的问题，我已经确认了，请问您现在方便吗？

Caller: 方便的，你说。

Agent: 根据我们的记录，您在系统中的状态是XXX，所以需要您...

Caller: 好的，明白了，我马上去处理。

→ 触发停止（模型 done）— 信息传达清楚，对方已承诺
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;局限与后续计划&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;当前局限：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;需要 root 手机&lt;/strong&gt;：ADB 拨号不要求 root，但蓝牙 HSP profile 管理在非 root 设备上有限制&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;仅限中文&lt;/strong&gt;：whisper 支持多语言，但 System Prompt 和 filler 目前只写中文&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Linux 单机部署&lt;/strong&gt;：MCP 要求 stdio 通道，不能远程调用&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单路通话&lt;/strong&gt;：HSP 只能处理一路双向音频，不能同时打两个电话&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要 USB 连接&lt;/strong&gt;：ADB over WiFi 也可以，但有线更稳定&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;计划中的改进：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 支持 ADB over WiFi，摆脱 USB 线&lt;/li&gt;
&lt;li&gt;[ ] 多语言支持（英文 System Prompt + 英文 filler）&lt;/li&gt;
&lt;li&gt;[ ] 回电调度：从 &lt;code&gt;done_reason&lt;/code&gt; 提取待确认事项，自动创建回电任务&lt;/li&gt;
&lt;li&gt;[ ] GPU 加速 whisper（CUDA / Apple Silicon）&lt;/li&gt;
&lt;li&gt;[ ] 通话录音存档，供 Agent 回顾历史对话&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;结语&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/7emotions/phone-agent&quot;&gt;phone-agent&lt;/a&gt; 给 AI Agent 装上了一个真正的「嘴巴」和「耳朵」——不是模拟的，是真实能打通电话的。&lt;/p&gt;
&lt;p&gt;它的核心价值不在于技术多么复杂（688 行 Python），而在于&lt;strong&gt;打通了 Agent 到真人之间的最后一公里&lt;/strong&gt;。Agent 能写代码、能搜文档、能部署服务，但在此之前，它需要一个人类来替它打电话。现在不需要了。&lt;/p&gt;
&lt;p&gt;如果你也在用 OpenCode 或其他支持 MCP 的 AI Agent 平台，欢迎尝试。项目开源在 GitHub，MIT 协议。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;项目地址：&lt;a href=&quot;https://github.com/7emotions/phone-agent&quot;&gt;github.com/7emotions/phone-agent&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;背景文章：&lt;a href=&quot;https://www.bilibili.com/video/BV1kvPnz9EcA&quot;&gt;Terminator-AI — 「普通人用 OpenClaw 实战：自动打电话」&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>让 AI Agent 也能「看」视频 —— 开发 video-transcript-skill</title><link>https://lorenzofeng.top/posts/video-transcript-skill/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/video-transcript-skill/</guid><description>OpenCode skill 插件开发：利用 yt-dlp + local Whisper 将在线视频转为文本，让 AI 编程助手从视频内容中学习</description><pubDate>Sat, 16 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;背景&lt;/h1&gt;
&lt;p&gt;在使用 AI 编程助手（如 OpenCode、Claude Code 等）时，有一个明显的痛点：&lt;strong&gt;AI Agent 本质上是文本模型，它无法「观看」视频&lt;/strong&gt;。当我遇到一个技术视频想丢给它学习时，只能自己看一遍再做总结转述。而 OpenCode 的 Skill 机制恰好提供了扩展能力的接口——我可以写一个 Skill，让 Agent 在需要时自动调用，把视频转成它读得懂的文本。&lt;/p&gt;
&lt;p&gt;于是有了&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/video-transcript-skill&quot;}&lt;/p&gt;
&lt;h1&gt;功能&lt;/h1&gt;
&lt;p&gt;这个 Skill 做的事情很直接：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;收到视频链接&lt;/strong&gt; → 触发 Skill&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尝试获取已有字幕&lt;/strong&gt; → 如果平台提供了字幕（B站、YouTube 通常有），几秒内返回结果&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;字幕不可用时&lt;/strong&gt; → 用 &lt;code&gt;yt-dlp&lt;/code&gt; 下载音频 → &lt;code&gt;ffmpeg&lt;/code&gt; 转码 → 本地 Whisper 转录 → 返回完整文本&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;支持的平台：&lt;strong&gt;Bilibili（B站）、YouTube、Vimeo、Twitch&lt;/strong&gt;，以及 &lt;a href=&quot;https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md&quot;&gt;yt-dlp 支持&lt;/a&gt; 的数百个视频网站。&lt;/p&gt;
&lt;h1&gt;架构设计&lt;/h1&gt;
&lt;h2&gt;为什么是 MCP？&lt;/h2&gt;
&lt;p&gt;Skill 本身只是一份指令文件（&lt;code&gt;SKILL.md&lt;/code&gt;），告诉 Agent 在什么场景下该做什么。真正的「能力」需要由外部的 MCP Server 提供。&lt;/p&gt;
&lt;p&gt;这里依赖的是 &lt;code&gt;video-toolkit&lt;/code&gt; MCP Server，它暴露了三个关键工具：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCP Tool&lt;/th&gt;
&lt;th&gt;功能&lt;/th&gt;
&lt;th&gt;耗时&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get-transcript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;获取平台已有字幕&lt;/td&gt;
&lt;td&gt;秒级&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;generate-subtitles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;下载音频 + Whisper 转录&lt;/td&gt;
&lt;td&gt;分钟级&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list-transcript-languages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查询可用字幕语言&lt;/td&gt;
&lt;td&gt;秒级&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;双轨策略：字幕优先，Whisper 兜底&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;视频链接
    │
    ├── 有字幕？──→ get-transcript ──→ 直接返回（秒级）
    │
    └── 无字幕？──→ yt-dlp 下载音频
                        │
                    ffmpeg 转 16kHz 单声道
                        │
                    faster-whisper 转录
                        │
                    返回带时间戳的完整文本
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个策略的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;大多数视频走得通第一条路径&lt;/strong&gt;，用户感知延迟很低&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第二条路径是终极兜底&lt;/strong&gt;，只要 &lt;code&gt;yt-dlp&lt;/code&gt; 能下载音频，就一定能出文本&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;技术选型&lt;/h1&gt;
&lt;h2&gt;yt-dlp — 通用视频下载&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/yt-dlp/yt-dlp&quot;&gt;yt-dlp&lt;/a&gt; 是 youtube-dl 的活跃 fork，支持数百个视频平台。用它做音频下载层，意味着不需要为每个平台写适配代码。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 只下载音频，转为 mp3
yt-dlp -x --audio-format mp3 &quot;https://www.bilibili.com/video/BVxxxxxx&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;faster-whisper — CPU 上也能用的转录&lt;/h2&gt;
&lt;p&gt;OpenAI 的 Whisper 效果很好，但原版在 CPU 上太慢。&lt;code&gt;faster-whisper&lt;/code&gt; 是 CTranslate2 加速的 Whisper 实现，同样的模型在 CPU 上快 4 倍，内存占用更低。&lt;/p&gt;
&lt;p&gt;实际测试数据（4 核 CPU，small 模型）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;视频时长&lt;/th&gt;
&lt;th&gt;转录耗时&lt;/th&gt;
&lt;th&gt;倍速&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 分钟&lt;/td&gt;
&lt;td&gt;~1 分钟&lt;/td&gt;
&lt;td&gt;5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 分钟&lt;/td&gt;
&lt;td&gt;~3 分钟&lt;/td&gt;
&lt;td&gt;5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 分钟&lt;/td&gt;
&lt;td&gt;~6.5 分钟&lt;/td&gt;
&lt;td&gt;4.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 小时&lt;/td&gt;
&lt;td&gt;~13 分钟&lt;/td&gt;
&lt;td&gt;4.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对于日常使用的 10-20 分钟视频，等待 2-3 分钟完全在可接受范围内。&lt;/p&gt;
&lt;h2&gt;Whisper 包装脚本&lt;/h2&gt;
&lt;p&gt;MCP Server 需要调用 Whisper，但 &lt;code&gt;faster-whisper&lt;/code&gt; 是 Python 库而非 CLI 工具。为了让 MCP Server 能像调用命令行工具一样使用它，我写了一个薄包装脚本（&lt;code&gt;scripts/whisper&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;Thin wrapper around faster-whisper for CLI usage.&quot;&quot;&quot;
import sys
import json
from faster_whisper import WhisperModel

model_size = &quot;small&quot;
model = WhisperModel(model_size, device=&quot;cpu&quot;, compute_type=&quot;int8&quot;)

segments, info = model.transcribe(sys.argv[1])
result = [{&quot;start&quot;: s.start, &quot;end&quot;: s.end, &quot;text&quot;: s.text} for s in segments]
print(json.dumps(result, ensure_ascii=False))
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;如何使用&lt;/h1&gt;
&lt;h2&gt;1. 安装依赖&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install yt-dlp faster-whisper
# ffmpeg 请根据系统自行安装
# Ubuntu/Debian: sudo apt install ffmpeg
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 安装 Whisper 包装脚本&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/video-transcript-skill.git
cp video-transcript-skill/scripts/whisper ~/.local/bin/whisper
chmod +x ~/.local/bin/whisper
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 配置 video-toolkit MCP Server&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/JamesANZ/video-transcript-mcp.git
cd video-transcript-mcp
npm install &amp;amp;&amp;amp; npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;opencode.jsonc&lt;/code&gt; 中添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;video-toolkit&quot;: {
  &quot;type&quot;: &quot;local&quot;,
  &quot;command&quot;: [&quot;node&quot;, &quot;/path/to/video-toolkit-mcp/dist/index.js&quot;],
  &quot;enabled&quot;: true,
  &quot;timeout&quot;: 600000,
  &quot;environment&quot;: {
    &quot;YT_DLP_PATH&quot;: &quot;yt-dlp&quot;,
    &quot;FFMPEG_PATH&quot;: &quot;ffmpeg&quot;,
    &quot;TRANSCRIPT_MCP_WHISPER_ENGINE&quot;: &quot;local&quot;,
    &quot;WHISPER_BINARY_PATH&quot;: &quot;/home/ubuntu/.local/bin/whisper&quot;,
    &quot;WHISPER_MODEL_PATH&quot;: &quot;small&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 安装 Skill&lt;/h2&gt;
&lt;p&gt;将 &lt;code&gt;SKILL.md&lt;/code&gt; 放到 OpenCode 的 skills 目录，重启即可。&lt;/p&gt;
&lt;h2&gt;5. 使用&lt;/h2&gt;
&lt;p&gt;直接发视频链接给 Agent：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;帮我总结一下这个视频 https://www.bilibili.com/video/BVxxxxxx&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Agent 会自动调用 Skill，先尝试获取字幕，失败则用 Whisper 转录，最终返回完整的文本内容供你分析和总结。&lt;/p&gt;
&lt;h2&gt;6. 案例&lt;/h2&gt;
&lt;p&gt;我在&lt;code&gt;Bilibili&lt;/code&gt;中找到了&lt;code&gt;Terminator-AI&lt;/code&gt;的视频&lt;a href=&quot;https://www.bilibili.com/video/BV1kvPnz9EcA&quot;&gt;普通人用 OpenClaw 实战系列(6) ：自动打电话&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;::bilibili{bvid=&quot;BV1kvPnz9EcA&quot;}&lt;/p&gt;
&lt;p&gt;以下是&lt;code&gt;Agent&lt;/code&gt;学习的结果&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./images/video-transcript-skill/ai_result.png&quot; alt=&quot;ai_result&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;局限与后续计划&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;当前局限：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CPU 转录速度有上限&lt;/strong&gt;：1 小时视频需要 ~13 分钟。如果有 GPU，速度可以提升 10 倍以上&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;部分平台需要认证&lt;/strong&gt;：B站某些视频需要登录才能获取字幕，此时会自动走 Whisper 降级路径&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;长视频可能超出上下文窗口&lt;/strong&gt;：超过 1 小时的视频转录文本可能超过 LLM 的上下文限制，需要分段处理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;计划中的改进：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 支持 GPU 加速（CUDA / Apple Silicon）&lt;/li&gt;
&lt;li&gt;[ ] 长视频自动分段 + 逐段总结&lt;/li&gt;
&lt;li&gt;[ ] 缓存已转录视频，避免重复转录&lt;/li&gt;
&lt;li&gt;[ ] 支持更多 Whisper 模型大小选择（tiny ~ large-v3）&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;结语&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/7emotions/video-transcript-skill&quot;&gt;video-transcript-skill&lt;/a&gt; 本质上做了一件事：&lt;strong&gt;给基于文本的 AI Agent 装上一双「耳朵」&lt;/strong&gt;。它不需要 Agent 理解视频画面，只需要让 Agent 听到视频里说了什么——而这已经覆盖了绝大多数技术教程、演讲、会议记录等场景。&lt;/p&gt;
&lt;p&gt;如果你也在用 OpenCode 或其他支持 Skill/MCP 的 AI 编程工具，欢迎尝试。项目开源在 GitHub，MIT 协议，任何形式的贡献都欢迎。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;项目地址：&lt;a href=&quot;https://github.com/7emotions/video-transcript-skill&quot;&gt;github.com/7emotions/video-transcript-skill&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>yolov10模型Docker训练环境搭建</title><link>https://lorenzofeng.top/posts/yolov10s/yolov10s/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/yolov10s/yolov10s/</guid><description>使用Docker容器化搭建yolov10训练环境</description><pubDate>Thu, 17 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;环境&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;docker compose&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;代码克隆&lt;/h2&gt;
&lt;p&gt;使用&lt;code&gt;git&lt;/code&gt;克隆仓库&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/robot-vision.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;::github{repo=&quot;7emotions/robot-vision&quot;}&lt;/p&gt;
&lt;h2&gt;数据集标定&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;yolo/&lt;/code&gt;文件夹下（后续都在该工作目录），新建&lt;code&gt;dataset/images/&lt;/code&gt;与&lt;code&gt;dataset/labels/&lt;/code&gt;。使用&lt;code&gt;labelimg&lt;/code&gt;标定数据集，&lt;code&gt;yolo&lt;/code&gt;支持的数据集格式为&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;class_id&amp;gt; &amp;lt;x_center&amp;gt; &amp;lt;y_center&amp;gt; &amp;lt;width&amp;gt; &amp;lt;height&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
&lt;code&gt;label&lt;/code&gt;文件的文件名需要与&lt;code&gt;image&lt;/code&gt; 文件的文件名保持一致。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;将图片文件与标签文件分别移动到&lt;code&gt;dataset/images/&lt;/code&gt;与&lt;code&gt;dataset/labels&lt;/code&gt;下。&lt;/p&gt;
&lt;h2&gt;数据集划分&lt;/h2&gt;
&lt;p&gt;运行&lt;code&gt;split.py&lt;/code&gt;划分数据集&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose run trainer python3 split.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;split.py&lt;/code&gt;会将数据集的&lt;code&gt;80%&lt;/code&gt;划分为&lt;strong&gt;训练集&lt;/strong&gt;，&lt;code&gt;20%&lt;/code&gt;划分为&lt;strong&gt;验证集&lt;/strong&gt;。对于缺失&lt;code&gt;labels&lt;/code&gt;的负样本，会新建空白&lt;code&gt;label&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;划分后，&lt;code&gt;dataset/&lt;/code&gt;的目录结构为&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dataset/
├── images/
│   ├── train/
│   │   ├── image1.jpg
│   │   ├── image2.png
│   │   └── ...
│   └── val/
│       ├── image3.jpg
│       ├── image4.jpeg
│       └── ...
└── labels/
    ├── train/
    │   ├── image1.txt
    │   ├── image2.txt
    │   └── ...
    └── val/
        ├── image3.txt
        ├── image4.txt
        └── ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;数据配置文件&lt;/h2&gt;
&lt;p&gt;训练之前需要编写数据配置文件。修改&lt;code&gt;conf.yml&lt;/code&gt;文件&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;train: dataset/images/train
val: dataset/images/val
nc: 7
names: [&quot;red target&quot;, &quot;blue area&quot;, &quot;blue target&quot;, &quot;starting point&quot;, &quot;black target&quot;,&quot;yellow target&quot;,&quot;red area&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中，&lt;code&gt;nc&lt;/code&gt;表示类别数目，&lt;code&gt;names&lt;/code&gt;表示类别名称列表。&lt;/p&gt;
&lt;h2&gt;预训练权重文件&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;yolov10&lt;/code&gt;仓库的&lt;a href=&quot;https://github.com/THU-MIG/yolov10/releases&quot;&gt;&lt;code&gt;Release&lt;/code&gt;&lt;/a&gt;中提供预训练权重文件。此处以&lt;code&gt;yolov10s.pt&lt;/code&gt;为例。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;THU-MIG/yolov10&quot;}&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
YOLOv10 系列通常会包含多个不同大小和复杂度的变体，以适应不同的计算资源和性能需求。这些变体通常会用后缀来区分，例如 -n（极小）、-s（小型）、-m（中型）、-l（大型）、-x（极大）等等。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
若更换预训练权重文件，请在&lt;code&gt;compose.yml&lt;/code&gt;中替换&lt;code&gt;yolov10s.pt&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;模型训练&lt;/h2&gt;
&lt;p&gt;启动容器进行训练&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看日志&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose logs trainer -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型将输出在&lt;code&gt;runs/&lt;/code&gt;目录下， 可查看到结果&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./labels.jpg&quot; alt=&quot;labels.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./labels_correlogram.jpg&quot; alt=&quot;labels_correlogram&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./train_batch0.jpg&quot; alt=&quot;train_batch0.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./train_batch1.jpg&quot; alt=&quot;train_batch1.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./train_batch2.jpg&quot; alt=&quot;train_batch2.jpg&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;模型导出&lt;/h2&gt;
&lt;p&gt;保存在&lt;code&gt;yolo/runs/deteçt/train/weights/&lt;/code&gt;下的&lt;code&gt;.pt&lt;/code&gt;文件可以通过以下命令导出为&lt;code&gt;.onnx&lt;/code&gt;模型文件。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose run trainer yolo export model=runs/detect/train/weights/best.pt format=onnx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.onnx&lt;/code&gt;模型会保存在&lt;code&gt;yolo/runs/detect/train/weights/&lt;/code&gt;目录下。&lt;/p&gt;
&lt;h2&gt;模型使用&lt;/h2&gt;
&lt;p&gt;以&lt;code&gt;cpp&lt;/code&gt;为例，采用&lt;code&gt;OpenCV&lt;/code&gt;导入&lt;code&gt;.onnx&lt;/code&gt;模型。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#include &amp;lt;iostream&amp;gt;
#include &amp;lt;fstream&amp;gt;
#include &amp;lt;string&amp;gt;
#include &amp;lt;vector&amp;gt;
#include &amp;lt;opencv2/opencv.hpp&amp;gt;
#include &amp;lt;opencv2/dnn.hpp&amp;gt;
#include &amp;lt;format&amp;gt;

int main() {
    std::string onnx_model_path = &quot;best.onnx&quot;;
    std::string classes_path = &quot;classes.txt&quot;;
    std::string image_path = &quot;test.jpg&quot;;

    float confidenceThreshold = 0.5;
    float nmsThreshold = 0.4;

    cv::Net net = cv::dnn::readNetFromONNX(onnx_model_path);
    if (net.empty()) {
        std::cerr &amp;lt;&amp;lt; &quot;Error: Could not load ONNX model: &quot; &amp;lt;&amp;lt; onnx_model_path &amp;lt;&amp;lt; std::endl;
        return -1;
    }
    std::cout &amp;lt;&amp;lt; &quot;ONNX model loaded successfully.&quot; &amp;lt;&amp;lt; std::endl;

    std::vector&amp;lt;std::string&amp;gt; classes;
    std::ifstream ifs(classes_path);
    std::string line;
    if (ifs.is_open()) {
        while (getline(ifs, line)) {
            classes.push_back(line);
        }
    } else {
        std::cerr &amp;lt;&amp;lt; &quot;Error: Could not open classes file: &quot; &amp;lt;&amp;lt; classes_path &amp;lt;&amp;lt; std::endl;
        return -1;
    }
    std::cout &amp;lt;&amp;lt; &quot;Loaded &quot; &amp;lt;&amp;lt; classes.size() &amp;lt;&amp;lt; &quot; classes.&quot; &amp;lt;&amp;lt; std::endl;

    cv::Mat frame = cv::imread(image_path);
    if (frame.empty()) {
        std::cerr &amp;lt;&amp;lt; &quot;Error: Could not read image: &quot; &amp;lt;&amp;lt; image_path &amp;lt;&amp;lt; std::endl;
        return -1;
    }
    int frameWidth = frame.cols;
    int frameHeight = frame.rows;

    cv::Mat blob;
    cv::dnn::blobFromImage(frame, blob, 1 / 255.0, cv::Size(640, 640), cv::Scalar(0, 0, 0), true, false);
    net.setInput(blob);

    cv::Mat output = net.forward();

    std::vector&amp;lt;int&amp;gt; classIds;
    std::vector&amp;lt;float&amp;gt; confidences;
    std::vector&amp;lt;cv::Rect&amp;gt; boxes;

    int rows = output.size[2];
    int cols = output.size[3];

    for (int i = 0; i &amp;lt; rows; ++i) {
        float confidence = output.at&amp;lt;float&amp;gt;(0, 0, i, 4);

        if (confidence &amp;gt; confidenceThreshold) {
            cv::Mat scores = output.row(i).colRange(5, cols);
            cv::Point classIdPoint;
            double maxScore;
            cv::minMaxLoc(scores, 0, &amp;amp;maxScore, 0, &amp;amp;classIdPoint);
            int classId = classIdPoint.x;

            if (maxScore &amp;gt; confidenceThreshold) {
                float centerX = output.at&amp;lt;float&amp;gt;(0, 0, i, 0) * frameWidth;
                float centerY = output.at&amp;lt;float&amp;gt;(0, 0, i, 1) * frameHeight;
                float width = output.at&amp;lt;float&amp;gt;(0, 0, i, 2) * frameWidth;
                float height = output.at&amp;lt;float&amp;gt;(0, 0, i, 3) * frameHeight;
                cv::Rect box(cv::Point(cvRound(centerX - width / 2), cvRound(centerY - height / 2)),
                             cv::Size(cvRound(width), cvRound(height)));

                classIds.push_back(classId);
                confidences.push_back(confidence * maxScore);
                boxes.push_back(box);
            }
        }
    }

    std::vector&amp;lt;int&amp;gt; indices;
    cv::dnn::NMSBoxes(boxes, confidences, confidenceThreshold, nmsThreshold, indices);

    for (int idx : indices) {
        cv::Rect box = boxes[idx];
        int classId = classIds[idx];
        float confidence = confidences[idx];

        cv::rectangle(frame, box, cv::Scalar(0, 255, 0), 2);
        
  std::string label = classes[classId] + &quot;: &quot; + std::format(&quot;{:.2f}&quot;, confidence);
        cv::putText(frame, label, cv::Point(box.x, box.y - 10), cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 255, 0), 2);
    }

    cv::imshow(&quot;Detected Objects&quot;, frame);
    cv::waitKey(0);
    cv::destroyAllWindows();

    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>AtomGit Pages 使用自定义域名</title><link>https://lorenzofeng.top/posts/blog-net/blog-net/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/blog-net/blog-net/</guid><description>采用nginx与caddy反向代理，为AtomGit Pages设置自定义域名</description><pubDate>Wed, 19 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前置条件&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;已经备案的域名&lt;/li&gt;
&lt;li&gt;已经配置Docker的云服务器&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;域名解析配置&lt;/h1&gt;
&lt;p&gt;为域名添加A记录，解析值为云服务器的IP地址。&lt;/p&gt;
&lt;h1&gt;云服务器配置&lt;/h1&gt;
&lt;p&gt;为云服务添加出站规则，开放&lt;code&gt;443&lt;/code&gt;端口与&lt;code&gt;80&lt;/code&gt;端口。&lt;/p&gt;
&lt;h1&gt;&lt;code&gt;Caddy&lt;/code&gt;配置&lt;/h1&gt;
&lt;p&gt;新建&lt;code&gt;Caddyfile&lt;/code&gt;，并写入&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yours.domain.com {
    reverse_proxy nginx:80
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
请修改&lt;code&gt;yours.domain.com&lt;/code&gt;为你的域名。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;&lt;code&gt;Nginx&lt;/code&gt;配置&lt;/h1&gt;
&lt;p&gt;新建&lt;code&gt;nginx.conf&lt;/code&gt;，并写入&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;events {
    worker_connections 1024;
}

http {
    server {
        listen 80;
        server_name yours.domain.com;

        location / {
            proxy_pass https://you.atomgit.net/your-blog/;
            proxy_set_header Host you.atomgit.net;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header User-Agent $http_user_agent;
            proxy_ssl_server_name on;
            proxy_ssl_protocols TLSv1.2 TLSv1.3;
            proxy_ssl_ciphers &apos;HIGH:!aNULL:!MD5&apos;;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
请修改&lt;code&gt;yours.domain.com&lt;/code&gt;为你的域名，&lt;code&gt;you.atomgit.net&lt;/code&gt;为你的AtomGit Pages的域名，&lt;code&gt;your-blog&lt;/code&gt;为你的博客的目录。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;&lt;code&gt;Docker&lt;/code&gt;配置&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;version: &apos;3.8&apos;

services:
    nginx:
        container_name: blog-nginx
        image: nginx:latest
        ports:
            - &quot;80:80&quot; 
        volumes:
            - ./nginx.conf:/etc/nginx/nginx.conf
        networks:
            - blognetwork

    caddy:
        container_name: blog-caddy
        image: caddy:latest
        ports:
            - &quot;443:443&quot;
        volumes:
            - ./Caddyfile:/etc/caddy/Caddyfile
        networks:
            - blognetwork

networks:
  blognetwork:
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;构建容器&lt;/h1&gt;
&lt;p&gt;运行以下指令构建容器&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;访问&lt;code&gt;https://yours.domain.com/&lt;/code&gt;以查看博客。&lt;/p&gt;
</content:encoded></item><item><title>国内自动化静态博客搭建</title><link>https://lorenzofeng.top/posts/blog-guide/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/blog-guide/</guid><description>采用Github Action向AtomGit部署Fuwari静态博客</description><pubDate>Fri, 14 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前言&lt;/h1&gt;
&lt;p&gt;最近在搭建静态博客，发现国内访问&lt;code&gt;Github Pages&lt;/code&gt;的速度很慢，于是想通过国内的服务器来部署静态博客。经过一番搜索，发现&lt;code&gt;AtomGit&lt;/code&gt;是一个很好的选择，它支持&lt;code&gt;Pages&lt;/code&gt;服务，并且在国内有较好的访问速度。&lt;/p&gt;
&lt;h1&gt;准备&lt;/h1&gt;
&lt;h2&gt;AtomGit&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://atomgit.com/&quot;&gt;AtomGit&lt;/a&gt;是国内由开放原子基金会运营的&lt;code&gt;Git&lt;/code&gt;托管平台，它支持&lt;code&gt;Pages&lt;/code&gt;服务，并且具有良好的访问速度。&lt;/p&gt;
&lt;p&gt;你需要先在&lt;code&gt;AtomGit&lt;/code&gt;上注册一个账号，并创建一个仓库，用于部署静态博客的站点构建文件。并且，该仓库需要开启&lt;code&gt;Pages&lt;/code&gt;服务。参考&lt;a href=&quot;https://docs.atomgit.com/app/pageshelp&quot;&gt;AtomGit Pages&lt;/a&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
开启Pages服务后，AtomGit会自动为你的仓库分配一个uri，例如&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;。你可以通过这个url来访问你的静态博客。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Github&lt;/h2&gt;
&lt;p&gt;你需要一个&lt;code&gt;Github&lt;/code&gt;账号，并且需要创建一个仓库，用于存放静态博客的源文件。这个仓库不需要开启&lt;code&gt;Pages&lt;/code&gt;服务。从该仓库中，你可以通过&lt;code&gt;Github Action&lt;/code&gt;来生成静态博客的站点文件，并将其部署到&lt;code&gt;AtomGit&lt;/code&gt;上。&lt;/p&gt;
&lt;h2&gt;Node.js （可选）&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Fuwari&lt;/code&gt;从&lt;code&gt;Node.js&lt;/code&gt;构建 ，你需要安装&lt;code&gt;Node.js Version 20&lt;/code&gt;。你可以从&lt;a href=&quot;https://nodejs.org/&quot;&gt;Node.js官网&lt;/a&gt;下载并安装。&lt;/p&gt;
&lt;h1&gt;开始&lt;/h1&gt;
&lt;h2&gt;1. Fork Fuwari&lt;/h2&gt;
&lt;p&gt;首先，你需要Fork &lt;a href=&quot;https://github.com/saicaca/fuwari&quot;&gt;Fuwari&lt;/a&gt; 到你的Github账号下。&lt;code&gt;Fuwari&lt;/code&gt;是基于&lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt;的静态博客模板。&lt;/p&gt;
&lt;p&gt;:sparkle: 功能特性&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] 基于 &lt;code&gt;Astro&lt;/code&gt; 和 &lt;code&gt;Tailwind CSS&lt;/code&gt; 开发&lt;/li&gt;
&lt;li&gt;[x] 流畅的动画和页面过渡&lt;/li&gt;
&lt;li&gt;[x] 亮色 / 暗色模式&lt;/li&gt;
&lt;li&gt;[x] 自定义主题色和横幅图片&lt;/li&gt;
&lt;li&gt;[x] 响应式设计&lt;/li&gt;
&lt;li&gt;[ ] 评论&lt;/li&gt;
&lt;li&gt;[x] 搜索&lt;/li&gt;
&lt;li&gt;[ ] 文内目录&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 修改配置文件&lt;/h2&gt;
&lt;p&gt;你可以通过配置文件 &lt;code&gt;src/config.ts&lt;/code&gt; 自定义博客。以我的配置文件为例。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import type {
  LicenseConfig,
  NavBarConfig,
  ProfileConfig,
  SiteConfig,
} from &apos;./types/config&apos;
import { LinkPreset } from &apos;./types/config&apos;

export const siteConfig: SiteConfig = {
  title: &apos;Lorenzo Feng&apos;,
  subtitle: &apos;Blog Site&apos;,
  lang: &apos;zh_CN&apos;,         // &apos;en&apos;, &apos;zh_CN&apos;, &apos;zh_TW&apos;, &apos;ja&apos;, &apos;ko&apos;, &apos;es&apos;, &apos;th&apos;
  themeColor: {
    hue: 250,         // Default hue for the theme color, from 0 to 360. e.g. red: 0, teal: 200, cyan: 250, pink: 345
    fixed: false,     // Hide the theme color picker for visitors
  },
  banner: {
    enable: true,
    src: &apos;assets/images/demo-banner.png&apos;,   // Relative to the /src directory. Relative to the /public directory if it starts with &apos;/&apos;
    position: &apos;center&apos;,      // Equivalent to object-position, only supports &apos;top&apos;, &apos;center&apos;, &apos;bottom&apos;. &apos;center&apos; by default
    credit: {
      enable: false,         // Display the credit text of the banner image
      text: &apos;&apos;,              // Credit text to be displayed
      url: &apos;&apos;                // (Optional) URL link to the original artwork or artist&apos;s page
    }
  },
  toc: {
    enable: true,           // Display the table of contents on the right side of the post
    depth: 2                // Maximum heading depth to show in the table, from 1 to 3
  },
  favicon: [    // Leave this array empty to use the default favicon
    // {
    //   src: &apos;/favicon/icon.png&apos;,    // Path of the favicon, relative to the /public directory
    //   theme: &apos;light&apos;,              // (Optional) Either &apos;light&apos; or &apos;dark&apos;, set only if you have different favicons for light and dark mode
    //   sizes: &apos;32x32&apos;,              // (Optional) Size of the favicon, set only if you have favicons of different sizes
    // }
  ]
}

export const navBarConfig: NavBarConfig = {
  links: [
    LinkPreset.Home,
    LinkPreset.Archive,
    LinkPreset.About,
    {
      name: &apos;GitHub&apos;,
      url: &apos;https://github.com/7emotions/astro-blog/&apos;,     // Internal links should not include the base path, as it is automatically added
      external: true,                               // Show an external link icon and will open in a new tab
    },
  ],
}

export const profileConfig: ProfileConfig = {
  avatar: &apos;assets/images/demo-avatar.png&apos;,  // Relative to the /src directory. Relative to the /public directory if it starts with &apos;/&apos;
  name: &apos;Lorenzo Feng&apos;,
  bio: &apos;An algorithm engineer of Alliance, NJUST&apos;,
  links: [
    {
        name: &apos;Telegram&apos;,
        icon: &apos;logos:telegram&apos;,
        url: &apos;https://t.me/lorenzofeng&apos;
    },
    {
      name: &apos;QQ&apos;,
      icon: &apos;mdi:qqchat&apos;,
      url: &apos;https://qm.qq.com/q/gszht217Fu&apos;,
    },
    {
      name: &apos;GitHub&apos;,
      icon: &apos;fa6-brands:github&apos;,
      url: &apos;https://github.com/7emotions&apos;,
    },
  ],
}

export const licenseConfig: LicenseConfig = {
  enable: true,
  name: &apos;CC BY-NC-SA 4.0&apos;,
  url: &apos;https://creativecommons.org/licenses/by-nc-sa/4.0/&apos;,
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 创建文章&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Astro&lt;/code&gt;框架是基于&lt;code&gt;Markdown&lt;/code&gt;的，所以你可以在&lt;code&gt;src/content/posts/&lt;/code&gt;目录中创建新的&lt;code&gt;Markdown&lt;/code&gt;文件，编辑文章内容。 你也可以在终端中执行 &lt;code&gt;pnpm new-post &amp;lt;filename&amp;gt;&lt;/code&gt; 创建新文章，并在&lt;code&gt;src/content/posts/&lt;/code&gt;目录中编辑。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;文章头格式&lt;/strong&gt;如下&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
title: My First Blog Post //文章标题
published: 2023-09-09 //文章发布日期
description: This is the first post of my new Astro blog.  //文章描述
image: ./cover.jpg  //这是文章封面，路径可以是相对路径，也可以是绝对路径
tags: [Foo, Bar] //文章标签
category: Front-end //文章分类
draft: false //是否为草稿
lang: jp      //仅当文章语言与 `config.ts` 中的网站语言不同时需要设置
---

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 本地构建（可选）&lt;/h2&gt;
&lt;p&gt;安装依赖&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install -g pnpm
pnpm install &amp;amp;&amp;amp; pnpm add sharp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行以下命令，可以在本地&lt;a href=&quot;https://localhost:4321/&quot;&gt;https://localhost:4321/&lt;/a&gt;预览博客&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;构建博客&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 部署&lt;/h2&gt;
&lt;p&gt;修改在根目录下的 &lt;code&gt;astro.config.mjs&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export default defineConfig({
    site: &quot;https://7emotions.atomgit.net&quot;, // &amp;lt;username&amp;gt;.atomgit.net
    base: &quot;/blog&quot;, // &amp;lt;repo-name&amp;gt;
    trailingSlash: &quot;always&quot;
    // ...
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将 &lt;code&gt;base&lt;/code&gt; 修改为你的仓库名称，将 &lt;code&gt;&amp;lt;username&amp;gt;&lt;/code&gt; 修改为你的用户名。例如，如果你的用户名是 &lt;code&gt;lorenzofeng&lt;/code&gt;，仓库名称是 &lt;code&gt;blog&lt;/code&gt;，那么 &lt;code&gt;base&lt;/code&gt; 应该是 &lt;code&gt;/blog&lt;/code&gt;，&lt;code&gt;site&lt;/code&gt; 应该是 &lt;code&gt;https://lorenzofeng.atomgit.net&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在根目录新建 &lt;code&gt;.github/workflows/astro.yml&lt;/code&gt;， 并写入以下内容&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: Deploy to AtomGit Pages

on:
  push:
    branches: [main] # 修改为Github源码仓库的分支名
  workflow_dispatch:

permissions:
  contents: read

jobs:
  deployment:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout your repository using git
        uses: actions/checkout@v4
        with:
          ref: main # 修改为Github源码仓库的分支名
      - name: Setup node
        uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Install dependence
        run: |
          npm install -g pnpm
          pnpm install
          pnpm add sharp
      - name: Build dist
        run: pnpm run build
      - name: Publish branch
        uses: 7emotions/branch-pub@v4
        with:
          token: ${{ secrets.ATOM_TOKEN }} 
          user: 7emotions # 修改为你的AtomGit用户名
          repo: 7emotions/blog # 修改为AtomGit的仓库路径
          github_domain: atomgit.com
          branch: pages # 要部署的分支名称
          folder: dist 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在&lt;code&gt;AtomGit&lt;/code&gt;的&lt;a href=&quot;https://atomgit.com/-/profile/tokens&quot;&gt;&lt;strong&gt;访问令牌&lt;/strong&gt;&lt;/a&gt;中创建个人令牌（&lt;code&gt;Personal Access Token&lt;/code&gt;），权限为&lt;code&gt;repo&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在&lt;code&gt;Github&lt;/code&gt;仓库&lt;code&gt;Settings&lt;/code&gt;中，找到&lt;code&gt;Secrets and variables&lt;/code&gt;，选择&lt;code&gt;Action&lt;/code&gt;。点击&lt;code&gt;New repository secret&lt;/code&gt;，添加&lt;code&gt;ATOM_TOKEN&lt;/code&gt;，值为你的&lt;code&gt;AtomGit&lt;/code&gt;的&lt;code&gt;Personal Access Token&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;推送代码到&lt;code&gt;Github&lt;/code&gt;仓库，等待&lt;code&gt;Github Action&lt;/code&gt;执行完成，即可在&lt;code&gt;AtomGit&lt;/code&gt;上看到新发布的分支。&lt;/p&gt;
&lt;p&gt;在&lt;code&gt;AtomGit&lt;/code&gt;的仓库中，点击设置-&amp;gt;&lt;code&gt;Pages&lt;/code&gt;，选择&lt;code&gt;pages&lt;/code&gt;分支，即可在&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;访问你的博客。&lt;/p&gt;
&lt;h1&gt;参考&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.lapis.cafe/posts/technicaltutorials/%E6%96%B0%E4%B8%80%E4%BB%A3%E9%9D%99%E6%80%81%E5%8D%9A%E5%AE%A2%E6%A1%86%E6%9E%B6astro%E7%9A%84%E9%83%A8%E7%BD%B2%E4%BC%98%E5%8C%96%E6%8C%87%E5%8D%97%E4%B8%8E%E4%BD%BF%E7%94%A8%E4%BD%93%E9%AA%8C/&quot;&gt;新一代静态博客框架Astro的部署优化指南与使用体验&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.atomgit.com/app/pageshelp&quot;&gt;AtomGit Pages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>国内静态博客一键部署</title><link>https://lorenzofeng.top/posts/onetap/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/onetap/</guid><description>Docker或Github Action一键部署Fuwari静态博客</description><pubDate>Fri, 14 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前言&lt;/h1&gt;
&lt;p&gt;手动部署以及博客配置参考&lt;a href=&quot;../blog-guide/&quot;&gt;国内自动化静态博客搭建&lt;/a&gt;。&lt;/p&gt;
&lt;h1&gt;准备&lt;/h1&gt;
&lt;h2&gt;AtomGit&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://atomgit.com/&quot;&gt;AtomGit&lt;/a&gt;是国内由开放原子基金会运营的&lt;code&gt;Git&lt;/code&gt;托管平台，它支持&lt;code&gt;Pages&lt;/code&gt;服务，并且具有良好的访问速度。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;一个账号与一个空仓库&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;你需要先在&lt;code&gt;AtomGit&lt;/code&gt;上注册一个账号，并创建一个&lt;strong&gt;空仓库&lt;/strong&gt;，用于部署静态博客的站点构建文件。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pages服务申请&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;该仓库需要开启&lt;code&gt;Pages&lt;/code&gt;服务。参考&lt;a href=&quot;https://docs.atomgit.com/app/pageshelp&quot;&gt;AtomGit Pages&lt;/a&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://atomgit.com/atomgit_operate/feedback/issues/create?name=3.Apply_Pages&amp;amp;dirType=1&amp;amp;page=issueTemplate&quot;&gt;点击此处申请Pages服务&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://atomgit.com/marketplace/pages?ref_app_id=4005817059772432&quot;&gt;点击此处安装Pages应用&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;开启Pages服务后，AtomGit会自动为你的仓库分配一个uri，例如&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;。你可以通过这个url来访问你的静态博客。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;访问令牌&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;你需要创建一个访问令牌（&lt;code&gt;Personal Access Token&lt;/code&gt;，&lt;code&gt;PAT&lt;/code&gt;），用于&lt;code&gt;Github Action&lt;/code&gt;访问你的仓库。&lt;a href=&quot;https://atomgit.com/-/profile/tokens&quot;&gt;点击此处生成&lt;/a&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;注意&lt;/strong&gt;: 访问令牌需要勾选&lt;code&gt;repo&lt;/code&gt;权限，否则&lt;code&gt;Github Action&lt;/code&gt;无法访问你的仓库。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Github&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;一个账号&lt;/strong&gt;
你需要一个&lt;code&gt;Github&lt;/code&gt;账号，并且需要创建一个仓库，用于存放静态博客的源文件。这个仓库不需要开启&lt;code&gt;Pages&lt;/code&gt;服务。从该仓库中，你可以通过&lt;code&gt;Github Action&lt;/code&gt;来生成静态博客的站点文件，并将其部署到&lt;code&gt;AtomGit&lt;/code&gt;上。&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;Github Action 部署&lt;/h1&gt;
&lt;h2&gt;1. Fork 7emotions/Fuwari&lt;/h2&gt;
&lt;p&gt;首先，你需要Fork 我的&lt;a href=&quot;https://github.com/7emotions/fuwari&quot;&gt;Fuwari&lt;/a&gt; 到你的Github账号下。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/zh/get-started/quickstart/fork-a-repo&quot;&gt;如何Fork仓库&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 配置Secret&lt;/h2&gt;
&lt;p&gt;在Fork后的仓库中，点击&lt;code&gt;Settings&lt;/code&gt;，然后点击&lt;code&gt;Secrets and variables&lt;/code&gt;，选择&lt;code&gt;Actions&lt;/code&gt;，点击&lt;code&gt;New repository secret&lt;/code&gt;，添加以下三个&lt;code&gt;Secret&lt;/code&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TOKEN&lt;/code&gt;：AtomGit的访问令牌(&lt;code&gt;PAT&lt;/code&gt;)，用于&lt;code&gt;Github Action&lt;/code&gt;访问你的AtomGit仓库。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;USER&lt;/code&gt;：AtomGit的用户名，用于&lt;code&gt;Github Action&lt;/code&gt;访问你的AtomGit仓库。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;REPO&lt;/code&gt;：AtomGit的仓库名，用于&lt;code&gt;Github Action&lt;/code&gt;访问你的AtomGit仓库。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;例如，我的AtomGit仓库的uri为&lt;code&gt;https://atomgit.com/&amp;lt;username&amp;gt;/&amp;lt;repo-name&amp;gt;&lt;/code&gt;，那么&lt;code&gt;USER&lt;/code&gt;为&lt;code&gt;&amp;lt;username&amp;gt;&lt;/code&gt;，&lt;code&gt;REPO&lt;/code&gt;为&lt;code&gt;&amp;lt;repo-name&amp;gt;&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;3. 启用Actions&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;Github&lt;/code&gt;仓库中，点击&lt;code&gt;Actions&lt;/code&gt;，选择&lt;code&gt;Deploy Pages&lt;/code&gt;，点击&lt;code&gt;Run workflow&lt;/code&gt;，选择分支为&lt;code&gt;main&lt;/code&gt;，点击&lt;code&gt;Run workflow&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;images/onetap/1.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;刷新页面，可以看到&lt;code&gt;Github Action&lt;/code&gt;正在运行。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;images/onetap/2.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;约莫1分钟后，出现如下提示，说明&lt;code&gt;Github Action&lt;/code&gt;运行完成。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;images/onetap/3.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当&lt;code&gt;Github Action&lt;/code&gt;运行完成后，你可以访问&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;查看你的静态博客。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;例如，我的AtomGit仓库的uri为&lt;code&gt;https://atomgit.com/7emotions/blog&lt;/code&gt;，那么我的静态博客的访问地址为&lt;code&gt;https://7emotions.atomgit.net/blog&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;Docker 部署&lt;/h1&gt;
&lt;h2&gt;1. Fork 7emotions/Fuwari&lt;/h2&gt;
&lt;p&gt;首先，你需要Fork 我的&lt;a href=&quot;https://github.com/7emotions/fuwari&quot;&gt;Fuwari&lt;/a&gt; 到你的Github账号下。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/zh/get-started/quickstart/fork-a-repo&quot;&gt;如何Fork仓库&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. Clone 仓库&lt;/h2&gt;
&lt;p&gt;在本地，使用&lt;code&gt;git clone&lt;/code&gt;命令克隆你的Fork仓库。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/&amp;lt;username&amp;gt;/fuwari.git
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 配置环境变量&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;fuwari&lt;/code&gt;目录下，创建一个&lt;code&gt;.env&lt;/code&gt;文件，并添加以下内容。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TOKEN=&amp;lt;AtomGit-Token&amp;gt;
USER=&amp;lt;username&amp;gt;
REPO=&amp;lt;repo-name&amp;gt;
GIT_DOMAIN=atomgit.com
BRANCH=pages
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;文中&lt;code&gt;&amp;lt;AtomGit-Token&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;username&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;repo-name&amp;gt;&lt;/code&gt;分别代表AtomGit的访问令牌(&lt;code&gt;PAT&lt;/code&gt;)、AtomGit的用户名、AtomGit的仓库名。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;4. 构建与运行&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;fuwari&lt;/code&gt;目录下，使用以下命令构建与运行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 访问博客&lt;/h2&gt;
&lt;p&gt;当&lt;code&gt;Docker&lt;/code&gt;容器运行完成后，你可以访问&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;查看你的静态博客。&lt;/p&gt;
&lt;h1&gt;博文发布&lt;/h1&gt;
&lt;p&gt;无论是&lt;code&gt;Github Action&lt;/code&gt;部署还是&lt;code&gt;Docker&lt;/code&gt;部署，每次向远端推送博文，会自动触发&lt;code&gt;Github Action&lt;/code&gt;，生成静态博客的站点文件，并将其部署到&lt;code&gt;AtomGit&lt;/code&gt;上。&lt;/p&gt;
</content:encoded></item></channel></rss>