<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://blog.einverne.info/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.einverne.info/" rel="alternate" type="text/html" hreflang="zh" /><updated>2026-09-08T07:55:22-05:00</updated><id>https://blog.einverne.info/feed.xml</id><title type="html">Verne in GitHub</title><subtitle>博客内容涵盖多个主题，包括 Jekyll、Linux 命令、编程语言学习笔记、各种产品体验、经验总结等。还深入探讨 Git、Java、Vim、Linux、Android 等技术领域，分享关于 Docker、Go、Spring、开源项目的见解。</subtitle><author><name>Ein Verne</name><email>git@einverne.info</email></author><entry><title type="html">Executor：多 Agents 和外部世界之间的统一代理层</title><link href="https://blog.einverne.info/post/2026/09/executor-integration-layer-for-ai-agents.html" rel="alternate" type="text/html" title="Executor：多 Agents 和外部世界之间的统一代理层" /><published>2026-09-07T00:00:00-05:00</published><updated>2026-09-07T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/09/executor-integration-layer-for-ai-agents</id><content type="html" xml:base="https://blog.einverne.info/post/2026/09/executor-integration-layer-for-ai-agents.html"><![CDATA[<p>同时使用 Claude Code，Codex，Pi，OpenClaw 等等 Agent 工具时有一个非常尴尬的状况，就是不同的 Agent 工具维护了不同的配置格式，如果要配置 MCP server 就需要分别到不同的配置文件中定义，相同的配置散落在系统的各个角落中。同一份 API Key 也需要重复粘贴到不同的配置中，更麻烦的是如果一旦有更新或者 API Key 变动就需要重复修改多个地方，我之前还调查过如何[[跨平台管理 MCP]]，主要的思路还是通过脚本和同步来对齐配置，但本质上还是在管理多个副本，直到我看到了 Executor 这个项目。</p>

<p><a href="https://github.com/UsefulSoftwareCo/executor">Executor</a> 项目的目的是为了简化各个 Agent 配置，在 Agent 和外部 API 之间做一层代理。集成只加一次，凭据只给一次，权限策略只设一次，然后所有 MCP 兼容的客户端都指向这一层，共享同一个工具目录。项目地址的作者是 Rhys Sullivan，之前在 Vercel、Microsoft、Epic Games 待过。</p>

<p><img src="https://pic.einverne.info/images/2026-09-07-10-00-00-executor-integration-layer.png" alt="AI Agent 与外部 API 之间的统一集成层" /></p>

<h2 id="它到底解决的是什么问题">它到底解决的是什么问题</h2>

<p>先说清楚 Executor 不是什么。它不是又一个 MCP server，也不是某个特定服务的封装。它的定位是集成层，或者说 MCP 网关，README 里的原话是 “The missing integration layer for AI agents”。</p>

<p>今天 agent 生态的现状是：每个客户端都是一座孤岛。你在 Claude Code 里接了 Linear 的 MCP server，Cursor 想用就得再接一遍；你给某个 agent 配了公司内部 API 的 token，另一个 agent 想调同一个接口，你得再找一遍那个 token 存在哪。这套模式在只有一个 agent 的时候完全没问题，但当你手上同时跑着桌面客户端、终端 CLI、云端 agent 的时候，配置的重复度和凭据的扩散范围就开始失控了。</p>

<p>更深一层的问题是权限。MCP 协议本身没有定义工具级别的授权模型，一个 MCP server 暴露出来的所有工具，对客户端来说要么全都能调，要么整个 server 不接。你没有办法说”这个 server 的读操作随便调，写操作必须先问我”。实际使用中这个粒度是不够的，尤其当 agent 有能力调用会产生真实副作用的接口时。</p>

<p>Executor 把这三件事——集成定义、凭据存储、权限策略——从客户端里抽出来，放到一个独立的服务里，然后通过 MCP 协议统一暴露出去。</p>

<h2 id="三个核心概念">三个核心概念</h2>

<p>Executor 的设计很简单，只有三个概念：</p>

<p>Integration 是集成本身，也就是你想接入的东西的定义。Executor 支持 MCP server、OpenAPI 规范、GraphQL 接口，以及 Google Discovery。README 里有一句话我觉得概括得很好：只要能用 JSON Schema 描述，它就能成为一个 integration。这也意味着接入方式非常直接，你有一份 OpenAPI 的 YAML 或者 JSON，扔进去就完事了，不需要为它单独写一个 MCP server 的包装层。这一点其实很关键，因为现实中大量内部服务都有 OpenAPI 文档，但几乎没有人会为它专门写 MCP server。</p>

<p>Connection 是集成的一个已配置实例。这里的设计有点意思：integration 和 connection 是一对多的。同一个 GitHub 的 OpenAPI 定义，你可以建三个 connection，分别用不同账号的 token；同一个内部 API，你可以建 staging 和 production 两个 connection，指向不同的 baseUrl。凭据是挂在 connection 上的，而不是挂在 integration 上。</p>

<p>Policy 是每个工具的权限级别，一共三档：allow 直接放行，require approval 调用时暂停等人工批准，block 完全禁止。关键在于默认值不是手工一个个点出来的，而是从规范里推导的。文档给的例子是 OpenAPI：GET 这类只读操作默认允许，写操作可以设成需要批准。这个默认值的推导逻辑很实用，因为一份稍具规模的 OpenAPI 文档动辄上百个 endpoint，指望人工逐个设策略是不现实的，能按 HTTP 方法自动分出安全和危险两档，剩下的手工微调量就小得多了。</p>

<h2 id="一个端点所有-agent">一个端点，所有 agent</h2>

<p>Executor 对外的形态就是一个 MCP endpoint。agent 说 MCP，Executor 在后面把请求路由到具体的集成上：对上游 MCP server 说 MCP，对 OpenAPI 和 GraphQL 说 HTTP，然后把结果原路返回。</p>

<p>这个代理结构带来的第一个好处是配置的解耦。因为客户端只认识 Executor 这一个端点，你在 Executor 里增删改上游服务，客户端完全不需要动。加了一个新集成，agent 那边自动就能看到新工具，不需要重启、不需要改配置文件、不需要在 5 个客户端里重复这个动作。这一点对我来说是最直接的收益，我加一个内部 API，Claude Code 和 Cursor 同时就有了。</p>

<p>第二个好处更重要，是凭据的隔离。文档里的说法是 credentials stay out，凭据存在 Executor 持有的 connection 上，在实际发起上游调用的那一刻才附加到请求里。agent 从头到尾看不到 token，也就不存在 token 意外进入模型上下文、被写进日志、或者被 prompt injection 套出来的风险。如果你的 agent 跑在沙箱里，或者是一个你不完全信任的第三方客户端，这个隔离是有实际意义的。</p>

<p>第三是策略在每次调用时统一执行。不管请求从哪个 agent 来，走的都是同一套 policy 判断。你不需要在每个客户端里分别配一遍权限，也不会出现某个客户端漏配导致权限失控的情况。新接入的上游服务自动继承同一套策略机制。</p>

<p>需要提醒一点，大部分 MCP 客户端只在启动时加载 server 列表，所以第一次把 Executor 接进去之后，通常需要重启客户端或者开一个新会话，工具才会出现。这个坑文档里明确提到了，我也确实踩了一次，以为是配置写错了。</p>

<h2 id="部署方式的选择">部署方式的选择</h2>

<p>Executor 提供了 4 种运行形态，功能完全一致，区别只在打包方式。</p>

<p>本地 CLI 是最轻的方式，<code class="language-plaintext highlighter-rouge">npm install -g executor</code> 装上，然后 <code class="language-plaintext highlighter-rouge">executor install</code> 把它注册成常驻后台服务，<code class="language-plaintext highlighter-rouge">executor web</code> 打开网页控制台。这个后台服务会跨重启保持运行，如果你只想临时跑一下不留痕迹，用 <code class="language-plaintext highlighter-rouge">executor web --foreground</code> 起一个前台进程就行。默认监听 <code class="language-plaintext highlighter-rouge">127.0.0.1:4788</code>，端口被占用时会自动挑一个空闲端口。需要 Node.js 20 以上。</p>

<p>桌面应用是同一个运行时套了个原生壳，Mac、Windows、Linux 都有，适合日常桌面环境；CLI 更适合无头服务器。</p>

<p>Executor Cloud 是官方托管版本，有免费额度，什么都不用装，直接登录、加集成、把 agent 指向托管端点。如果你用的是云端 agent，这条路是唯一能走通的，因为云端 agent 连不到你本机的 127.0.0.1。</p>

<p>自托管有两条路径。[[Docker]] 版本是 <code class="language-plaintext highlighter-rouge">ghcr.io/usefulsoftwareco/executor-selfhost:latest</code>，单个容器里打包了 API、MCP、认证、代码执行和 Web UI，数据落在一个 SQLite 文件里，暴露 4788 端口，零配置起步。[[Cloudflare]] 版本是部署到你自己账号下的一个 Worker，用 Cloudflare Access 做认证，用 D1 做存储。</p>

<p>选择的判断标准其实很清晰：如果所有 agent 都在本机，用本地版本，桌面环境选 App，服务器选 CLI；如果需要多台机器或者云端 agent 访问同一份目录，就上托管或自托管。我自己的用法是 Docker 自托管，跑在家里的 NAS 上，这样笔记本、台式机和几个服务器脚本共享同一份集成目录，同时数据完全在自己手上。</p>

<h2 id="上手的实际流程">上手的实际流程</h2>

<p>接入客户端用的是 <code class="language-plaintext highlighter-rouge">add-mcp</code> 这个工具，它会自动检测你当前的 MCP 客户端并写入配置：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx add-mcp http://127.0.0.1:4788/mcp <span class="nt">--transport</span> http <span class="nt">--name</span> executor
</code></pre></div></div>

<p>如果要走 stdio 传输：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx add-mcp <span class="s2">"executor mcp"</span> <span class="nt">--name</span> executor
</code></pre></div></div>

<p>添加集成可以在 Web UI 里点 Add Integration，也可以走 CLI。这里有个细节值得注意：当 OpenAPI 文档里的 <code class="language-plaintext highlighter-rouge">servers</code> 字段用的是相对路径时，需要显式传 <code class="language-plaintext highlighter-rouge">baseUrl</code>，否则请求不知道该发到哪里。很多内部服务生成的 OpenAPI 文档都有这个问题，第一次接的时候容易卡住。</p>

<p>日常会用到的 CLI 命令大概是这几个：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>executor tools search &lt;query&gt;        <span class="c"># 在目录里搜工具</span>
executor call &lt;path...&gt;              <span class="c"># 直接调用某个工具</span>
executor tools integrations          <span class="c"># 列出所有集成</span>
executor tools describe &lt;tool&gt;       <span class="c"># 查看工具的详细定义</span>
executor resume <span class="nt">--execution-id</span> &lt;<span class="nb">id</span><span class="o">&gt;</span>  <span class="c"># 恢复一次被暂停的执行</span>
executor daemon status               <span class="c"># 查看后台服务状态</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">executor resume</code> 对应的就是 policy 里 require approval 那一档，调用被挂起之后用它继续。这些命令都会自动拉起本地 daemon，不需要手动先启动。</p>

<p>如果要在自己的代码里用，官方提供了 TypeScript SDK，同时给了 Promise 和 Effect 两套 API：</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">createExecutor</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@executor-js/sdk/promise</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">openApiPlugin</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@executor-js/plugin-openapi/promise</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="使用中的一些判断">使用中的一些判断</h2>

<p>用下来我觉得它最适合的场景，是你手上有多个 agent 客户端，并且有一批自己的、非公开的 API 需要接进去。如果你只用一个 Claude Code，接的也都是现成的公开 MCP server，那 Executor 引入的这一层收益不大，反而多了一个需要维护的服务。它的价值随着 agent 数量和自有 API 数量的增长而放大。</p>

<p>OpenAPI 直接导入这条路是我认为最被低估的能力。写一个 MCP server 需要理解协议、搭脚手架、处理传输层，而扔一份 OpenAPI 文档进去是零成本的。公司内部服务基本都有 Swagger 文档，这意味着让 agent 接触内部系统的门槛一下子降到了几乎为零。当然这也是双刃剑，接得越容易，就越需要认真对待 policy 那一层。</p>

<p>关于 policy ，默认按 HTTP 方法分档。但是 GET 也可能是危险的，比如一个导出全量用户数据的 GET 接口；POST 也可能完全无害，比如一个搜索接口。所以自动推导出来的默认值应该被当成待审阅的草稿，而不是最终配置。特别是刚导入一份大的 OpenAPI 文档之后，值得花点时间把明显敏感的接口手动降级。目前文档里对批准流程的细节说得不多，谁能批准、请求怎么呈现、有没有超时、批准是否会被记住，这些都还不清楚，需要自己试。</p>

<p>另一个需要清醒认识的是，加这一层意味着多了一个单点。Executor 挂了，所有 agent 的所有工具一起挂。本地跑还好，如果是团队共享的自托管实例，这个可用性问题得认真考虑。相应地，它也成了一个高价值目标——一个集中存放了你所有 API 凭据的服务，本身的安全边界需要被认真对待，尤其是自托管暴露到公网的情况。Cloudflare 版本用 Cloudflare Access 做认证这个选择，某种程度上也是在回应这个问题。</p>

<p>从生态位上看，Executor 经常被拿来和 Composio 这类服务比较，核心差异在于开源和可自托管。Composio 是托管的商业服务，你的凭据存在它那里；Executor 你可以完全跑在自己的机器或者自己的 Cloudflare 账号里。对于凭据敏感的场景，这个差异是决定性的。</p>

<h2 id="最后">最后</h2>

<p>我觉得 Executor 真正抓住的，是 agent 生态里一个还没被认真对待的结构性问题：随着一个人同时使用的 agent 从 1 个变成 5 个，集成配置的复杂度不是线性增长，而是集成数乘以客户端数的乘积增长。用同步配置文件的方式去对抗这个乘法，只能缓解，不能解决。把集成提取成一个独立的、被所有客户端共享的层，才是从根上消掉那个乘数。</p>

<p>它现在还很年轻，2026 年才起步，文档里不少地方——尤其是批准流程和沙箱执行——还没写透，policy 模型也还比较粗。但方向我认为是对的。MCP 协议解决了 agent 和工具之间的通信标准，但没有解决工具的管理、认证和授权，那部分空白总归需要有东西来填。Executor 是目前我看到的填法里比较完整的一个：概念足够少，部署方式足够多，而且完全开源可自托管。</p>

<h2 id="related">related</h2>

<ul>
  <li>[[FumaDB]]</li>
  <li>[[Pi]]</li>
  <li>[[Emdash]]</li>
  <li>[[Composio]]</li>
</ul>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="产品体验" /><category term="executor" /><category term="mcp" /><category term="ai-agent" /><category term="claude-code" /><category term="openapi" /><category term="graphql" /><category term="self-hosted" /><category term="developer-tools" /><category term="api-gateway" /><category term="integration" /><summary type="html"><![CDATA[Executor 是一个开源的 AI Agent 集成层，把 MCP server、OpenAPI 规范、GraphQL 接口统一成一个工具目录，配置一次、认证一次、设定一次策略，然后所有 MCP 客户端共享。这篇文章讲清楚它的核心概念、MCP 代理机制、四种部署方式，以及我认为它真正解决的问题和目前的局限。]]></summary></entry><entry><title type="html">把 Android 手机当成 USB 无线网卡：以及那些被低估的安卓妙用</title><link href="https://blog.einverne.info/post/2026/08/android-phone-as-usb-network-adapter-and-more.html" rel="alternate" type="text/html" title="把 Android 手机当成 USB 无线网卡：以及那些被低估的安卓妙用" /><published>2026-08-21T00:00:00-05:00</published><updated>2026-08-21T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/08/android-phone-as-usb-network-adapter-and-more</id><content type="html" xml:base="https://blog.einverne.info/post/2026/08/android-phone-as-usb-network-adapter-and-more.html"><![CDATA[<p>昨天我把一块装着 [[Proxmox VE]] 系统的硬盘从之前的 SER8 拆下来，插到 4 盘位的 Me Pro 上，本以为开机就能继续用，结果卡在了最基础的一步：Me Pro 的物理网口和我原来的网口对不上，物理网口变更了名字导致无法上网。屏幕上 PVE 的控制台在闪，<code class="language-plaintext highlighter-rouge">ip addr</code> 里却无法找到两个物理网口，Web 管理界面自然也打不开。</p>

<p><img src="https://pic.einverne.info/images/2026-08-21-10-00-00-android-usb-network-adapter.png" alt="一台安卓手机通过 USB 线连接服务器，充当无线网卡" /></p>

<p>在和 Claude 交流寻找解决方案的过程中，我看到 Claude 说「用手机 USB 共享网络」，先确保 PVE 有网络，然后利用网络更新 Kernel。我开始还有点疑惑，这样也可以，但是手边正好有一台 Pixel，顺手就用 Type-C 数据线链接了，然后在 Android 端切换到 USB tethering，在 PVE 查看，立马就能看到多出一张网卡，利用 <code class="language-plaintext highlighter-rouge">dhclient enxxx</code> 就可以获取 IP 地址，立马就能 ping 通网络。</p>

<p>这个功能是，当手机自己连着 Wi-Fi 的时候，它同样可以把这份 Wi-Fi 网络通过 USB 线转发出去。换句话说，一根数据线加一台安卓手机，就等价于一张即插即用的 USB 无线网卡。</p>

<p>这件事让我开始重新审视抽屉里那几台退役的安卓手机。它们其实是完整的 ARM 计算机：有 CPU、有内存、有存储、有电池（相当于自带 UPS）、有 Wi-Fi 和蓝牙、有摄像头和屏幕、有 USB OTG。我们只是习惯性地把它们当作「淘汰的手机」，而不是「一台便宜的、带屏幕的、待机功耗只有几瓦的小型服务器」。</p>

<h2 id="从一次救急说起usb-网络共享到底做了什么">从一次救急说起，USB 网络共享到底做了什么</h2>

<p>要理解为什么这招好用，得先知道手机在 USB 那端到底扮演了什么角色。安卓设备通过 USB 连接电脑时，可以以不同的 USB Gadget 身份出现：MTP 模式下它是一个媒体设备，ADB 模式下它是一个调试设备，而打开 USB 网络共享之后，它把自己声明成了一个 USB 网络适配器。</p>

<p>具体用的协议一般是 RNDIS（Remote NDIS，微软定义的一套通过 USB 承载以太网帧的规范），近几年不少设备也改用了更标准、效率更高的 CDC-NCM。这两种协议在 Linux 内核里都有现成的驱动，分别是 <code class="language-plaintext highlighter-rouge">rndis_host</code> 和 <code class="language-plaintext highlighter-rouge">cdc_ncm</code>，属于早就编译进主流发行版内核的东西。所以主机侧不需要装任何厂商驱动，插上线之后内核就会枚举出一张新的以太网接口，传统命名是 <code class="language-plaintext highlighter-rouge">usb0</code>，在启用了 systemd 可预测网卡命名的系统上则是 <code class="language-plaintext highlighter-rouge">enx</code> 加上 MAC 地址的形式，比如 <code class="language-plaintext highlighter-rouge">enx0a1b2c3d4e5f</code>。</p>

<p>有意思的是手机这一侧。它并不是简单地做一个透明网桥，而是实实在在跑了一套小型路由：手机在这个 USB 网络接口上给自己分配 192.168.42.129 这个地址，同时启动一个 DHCP 服务，把 192.168.42.0/24 网段的地址发给通过 USB 连过来的主机，然后对出站流量做 NAT，把它们转发到当前的上游链路。这个上游链路可以是移动数据，也可以是手机正连着的 Wi-Fi。安卓的 tethering 框架并不关心上游是什么，它只负责把默认路由指向那条可用的链路。这正是「手机变无线网卡」的关键：Wi-Fi 进，USB 出。</p>

<p>顺带一提，Wi-Fi 热点用的是 192.168.43.0/24，蓝牙共享用的是 192.168.44.0/24，这三个网段是安卓 tethering 的固定约定。知道这个在排查问题时很有用，看到 192.168.42.129 这个网关地址，你就能确定链路走的是 USB 而不是别的。</p>

<h2 id="在-linux-主机上把这条链路跑起来">在 Linux 主机上把这条链路跑起来</h2>

<p>PVE 基于 Debian，所以下面的步骤在绝大多数 Debian 系发行版上通用。先把线插上，在手机的设置里找到「网络和互联网 - 热点与网络共享 - USB 网络共享」并打开。注意这个开关往往是灰的，直到系统检测到 USB 数据连接才会变为可点，而且它要求你用的是数据线而不是只能充电的线，这个坑我踩过不止一次。</p>

<p>打开之后回到主机，先确认内核有没有认出设备：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lsusb
ip <span class="nb">link
</span>dmesg | <span class="nb">tail</span> <span class="nt">-20</span>
</code></pre></div></div>

<p>正常情况下 <code class="language-plaintext highlighter-rouge">ip link</code> 里会多出一张 <code class="language-plaintext highlighter-rouge">usb0</code> 或 <code class="language-plaintext highlighter-rouge">enx</code> 开头的接口，<code class="language-plaintext highlighter-rouge">dmesg</code> 里能看到类似 <code class="language-plaintext highlighter-rouge">rndis_host ... register 'rndis_host' at usb-0000:00:14.0-2, RNDIS device, 0a:1b:2c:3d:4e:5f</code> 的日志。如果什么都没出现，先手动加载一次驱动：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>modprobe rndis_host
modprobe cdc_ether
modprobe cdc_ncm
</code></pre></div></div>

<p>接口出来之后，临时用的话一条命令就够了：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ip <span class="nb">link set </span>usb0 up
dhclient usb0
</code></pre></div></div>

<p>拿到地址后 <code class="language-plaintext highlighter-rouge">ip route</code> 里会出现指向 192.168.42.129 的默认路由，此时 PVE 的 Web 界面就能通过这个新地址访问了。如果你希望它持久生效，可以在 <code class="language-plaintext highlighter-rouge">/etc/network/interfaces</code> 里加一段：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>allow-hotplug usb0
iface usb0 inet dhcp
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">allow-hotplug</code> 而不是 <code class="language-plaintext highlighter-rouge">auto</code> 是有讲究的，手机不一定一直插着，用 <code class="language-plaintext highlighter-rouge">auto</code> 会让开机时因为等待这张不存在的网卡而拖慢启动。</p>

<p>有一点需要提醒：这套方案更适合救急和临时维护，不适合长期作为服务器的主链路。手机会不断被主机供电，长时间维持在满电状态对锂电池不友好，而且系统更新、电话来电、后台清理都可能中断这条链路。我的做法是把它当成「带外管理通道」，用它把机器救活、把正式网络配置好，然后拔掉。</p>

<h2 id="在-pve-上使用-android-usb-gadget">在 PVE 上使用 Android USB Gadget</h2>

<p>PVE 的特殊之处在于它既是宿主机也是虚拟化平台，所以这张「手机网卡」有两种用法，要根据目的选择。</p>

<p>第一种是留在宿主机上，也就是我前面救急时用的方式。适合的场景是宿主机自己失联了，你需要一条通道进入 Web 管理界面。配置就是上面那几条命令，不需要动虚拟化相关的东西。如果你还希望虚拟机也能借道上网，不要试图把 <code class="language-plaintext highlighter-rouge">usb0</code> 桥接进 <code class="language-plaintext highlighter-rouge">vmbr0</code>，这条路走不通。原因在于 RNDIS 接口只会为它对面的那一个 MAC 地址学习和转发，安卓侧不接受来自多个 MAC 的帧，桥接之后虚拟机发出的包能出去但回不来。正确的做法是在宿主机上做 NAT：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sysctl <span class="nt">-w</span> net.ipv4.ip_forward<span class="o">=</span>1
iptables <span class="nt">-t</span> nat <span class="nt">-A</span> POSTROUTING <span class="nt">-o</span> usb0 <span class="nt">-j</span> MASQUERADE
</code></pre></div></div>

<p>这样挂在 <code class="language-plaintext highlighter-rouge">vmbr0</code> 上的虚拟机把网关指向宿主机，就能通过手机出网。要持久化的话把 <code class="language-plaintext highlighter-rouge">net.ipv4.ip_forward=1</code> 写进 <code class="language-plaintext highlighter-rouge">/etc/sysctl.d/</code>，NAT 规则用 <code class="language-plaintext highlighter-rouge">iptables-persistent</code> 保存。</p>

<p>第二种是把手机整个直通给某台虚拟机，比如你想让 OpenWrt 或者软路由虚拟机直接管理这条上行链路。在 PVE 的 Web 界面里，选中虚拟机进入硬件页，添加 USB 设备，这里会给出两种绑定方式：按厂商和设备 ID，或者按 USB 端口。</p>

<p>对安卓设备一定要选按端口。这是个容易忽略但很关键的细节：手机在切换 USB 模式时会重新枚举，Product ID 跟着变，比如 Pixel 在纯充电、MTP、ADB、网络共享几种状态下报的 ID 各不相同（Vendor ID 固定是 Google 的 18d1，Product ID 却会在 4ee1、4ee2、4ee7 之间跳）。如果你按 ID 绑定，用户在手机上一点「USB 网络共享」，设备就从虚拟机眼前消失了。按端口绑定则只认物理插口，无论手机怎么切换模式都能保持连接。</p>

<p>命令行的写法是先用 <code class="language-plaintext highlighter-rouge">lsusb -t</code> 找到总线和端口号，再挂给虚拟机：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lsusb <span class="nt">-t</span>
qm <span class="nb">set </span>100 <span class="nt">-usb0</span> <span class="nv">host</span><span class="o">=</span>1-4
</code></pre></div></div>

<p>其中 <code class="language-plaintext highlighter-rouge">1-4</code> 表示 1 号总线的 4 号端口，<code class="language-plaintext highlighter-rouge">100</code> 是虚拟机的 VMID。如果插的是 USB 3 口，可以加上 <code class="language-plaintext highlighter-rouge">usb3=1</code>。这个操作支持热插拔，虚拟机不需要重启就能看到新设备，虚拟机内部同样会出现一张 <code class="language-plaintext highlighter-rouge">usb0</code>，配置方法和前面完全一致。</p>

<p>两种方式不能同时使用。设备一旦直通给虚拟机，宿主机上的 <code class="language-plaintext highlighter-rouge">usb0</code> 就会消失，所以如果你的目的是抢救宿主机本身，老老实实用第一种。</p>

<h2 id="旧手机是一台被低估的-arm-服务器">旧手机是一台被低估的 ARM 服务器</h2>

<p>救急之后我顺着这个思路想下去，安卓能做的远不止转发网络。最有想象空间的是 [[Termux]]，它在不 root 的前提下提供了一个相当完整的 Linux 用户空间环境，有自己的包管理器 <code class="language-plaintext highlighter-rouge">pkg</code>，能装 Python、Node.js、Go、Rust、git、ffmpeg、nginx、openssh 这些常规工具。你可以在手机上跑一个 sshd，然后从笔记本 ssh 进去，体验和登录一台小型 VPS 没有本质区别。</p>

<p>实际能落地的场景不少：把 [[Syncthing]] 装在旧手机上，它就是一个永远在线的同步节点，比常开一台 NAS 省电得多；用 Termux 跑定时任务抓取数据、做备份；跑一个轻量的下载器，配合外接的 OTG 硬盘当离线下载盒子；甚至可以跑 [[copyparty]] 这类文件服务，把手机的存储变成局域网里的文件共享点。一台闲置的旧机器待机功耗大概在 2 到 5 瓦之间，比树莓派还低，而且自带屏幕和电池。</p>

<p>如果你需要的是更完整的发行版而不只是 Termux 的环境，[[Andronix]] 或者 Termux 自带的 <code class="language-plaintext highlighter-rouge">proot-distro</code> 可以在用户态跑起 Debian、Ubuntu、Arch 的根文件系统。性能会因为 proot 的系统调用拦截而打折扣，但对于跑一些依赖 glibc 的软件是够用的。</p>

<h2 id="投屏反向控制与调试">投屏、反向控制与调试</h2>

<p>[[Scrcpy]] 是我用得最频繁的工具之一。它通过 ADB 把一个精简的 server 推到手机上，用 H.264 编码把屏幕流传回电脑，同时把电脑的键鼠事件注入回手机。延迟通常在 35 到 70 毫秒之间，实际体验接近于手机变成了电脑的一个窗口，可以直接用键盘打字、用鼠标操作，还能拖拽文件安装 APK。不需要 root，也不需要在手机上装任何应用。</p>

<p>配合 ADB 还能玩出更多花样。<code class="language-plaintext highlighter-rouge">adb reverse tcp:8080 tcp:8080</code> 可以做反向端口转发，让手机上的应用访问到你电脑本地的服务，调试移动端对接本地 API 的时候特别方便。<code class="language-plaintext highlighter-rouge">adb shell</code> 则是一个完整的命令行入口，很多在 UI 上被厂商藏起来的设置项，都能通过 <code class="language-plaintext highlighter-rouge">settings put</code> 直接改。</p>

<h2 id="摄像头显示器与各种外设">摄像头、显示器与各种外设</h2>

<p>Android 14 开始，系统原生支持把手机作为 USB 摄像头使用，插上电脑就会被识别成一个标准 UVC 设备，不需要装任何驱动或者第三方软件。对于手上有旗舰旧机的人来说，这基本是白捡了一个画质远超普通笔记本内置摄像头的网络摄像头。如果你的系统版本较低，DroidCam 或者 IP Webcam 这类应用也能达到类似效果，只是要走 Wi-Fi 或者 USB 转发，多一层配置。</p>

<p>反过来，手机也可以当显示器。spacedesk 这类工具把手机变成 Windows 的扩展屏，出差时带一台平板或者旧手机，就多了一块放监控面板、放文档的副屏。</p>

<p>USB OTG 则打开了另一扇门。旧手机接上 OTG 线之后可以读取 U 盘、SD 读卡器、移动硬盘，也可以接键盘鼠标。我见过有人把旧手机加 OTG 键盘当成一个极简的写作机器，续航一整天。</p>

<h2 id="应急启动盘与串口控制台">应急启动盘与串口控制台</h2>

<p>这两个用法比较小众，但真到需要的时候特别救命。</p>

<p>DriveDroid 可以把手机模拟成一个 USB 存储设备或者光驱，直接把手机里存放的 ISO 镜像挂载给目标电脑启动。这意味着你不需要随身带 U 盘和 [[Ventoy]]，手机里存几个常用的救援镜像就够了。代价是这个功能需要 root 权限，因为它要操作内核的 USB Gadget 配置接口。</p>

<p>另一个是串口控制台。很多服务器、交换机、软路由的管理口是 RJ45 转串口或者 USB 串口，配一根 USB 转 TTL 的线（CH340 或者 FTDI 芯片都行）和一个 OTG 转接头，再装一个 Serial USB Terminal 之类的应用，手机就变成了一台便携的串口终端。机房里不用再抱着笔记本蹲在机柜前，这个体验的差别是巨大的。</p>

<h2 id="网络诊断与抓包">网络诊断与抓包</h2>

<p>PCAPdroid 是一个不需要 root 的抓包工具，它的实现思路很巧妙：利用安卓的 VpnService API 把本机流量引到自己的虚拟接口上，然后落盘成 PCAP 文件。你可以按应用维度过滤，看某个 App 究竟往哪些域名发了请求，也可以把抓到的包导出用 Wireshark 分析。对于研究某个应用的网络行为、排查国内 App 的隐私问题，这是最低门槛的方案。</p>

<p>再加上各种 Wi-Fi 分析工具能看信道占用和信号强度，手机其实是一个相当称职的现场网络诊断设备。装修布网络、排查家里 Wi-Fi 死角的时候，比拿电脑方便太多。</p>

<h2 id="那些容易踩的坑">那些容易踩的坑</h2>

<p>数据线是第一个坑，也是最常见的一个。很多随手机附赠的线或者从充电宝里翻出来的线只有电源触点，没有数据线芯，插上去手机只会显示充电，USB 网络共享的开关始终点不亮。换一根确定能传数据的线，能省掉半小时的排查。</p>

<p>后台被杀是第二个坑。国内定制系统的省电策略非常激进，Termux 里跑着的服务可能在你锁屏几分钟后就被清掉。对策是在系统设置里把这个应用加入电池优化白名单，同时在 Termux 里执行 <code class="language-plaintext highlighter-rouge">termux-wake-lock</code> 持有唤醒锁。如果需要开机自启，装一个 Termux:Boot 插件，把启动脚本放到 <code class="language-plaintext highlighter-rouge">~/.termux/boot/</code> 下。</p>

<p>还有一个比较隐蔽的问题：从 Android 12 开始，系统引入了 phantom process 限制，会主动杀掉应用派生出的子进程，默认上限是 32 个。这对普通应用没影响，但 Termux 里跑多个服务很容易触发，表现为进程莫名其妙消失。可以通过 ADB 关掉这个监控：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adb shell device_config set_sync_disabled_for_tests persistent
adb shell device_config put activity_manager max_phantom_processes 2147483647
</code></pre></div></div>

<p>需要注意这个设置在部分系统重启后会失效，需要重新执行。</p>

<p>最后是应用来源。Termux 的 Google Play 版本早已停止维护，功能残缺且不再更新，务必从 F-Droid 或者官方 GitHub Release 安装。这类工具类应用普遍存在类似情况，装之前多确认一下渠道。</p>

<h2 id="最后">最后</h2>

<p>这次 PVE 迁移带来的最大收获是让我意识到自己对手上工具的想象力实在有限。一台安卓手机被我们默认框定在「打电话、刷视频、拍照」的用途里，但它的底层是一个跑着 Linux 内核的通用计算设备，具备网络栈、USB Gadget、传感器、编解码硬件这些完整的能力。厂商的 UI 只是把这些能力包装成了消费级的形态，而这些能力本身一直都在。</p>

<p>抽屉里那几台旧手机，我打算挑一台出来常年插着电，跑 Termux 加 Syncthing 当同步节点，顺便当成随时可用的救急网卡。它的性能可能不如一台四五百块的迷你主机，但它已经在那里了，边际成本是零。在折腾家庭实验室这件事上，先把已有的东西用起来，往往比再买一件新的更有成就感。</p>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="经验总结" /><category term="android" /><category term="usb-tethering" /><category term="rndis" /><category term="termux" /><category term="proxmox-ve" /><category term="scrcpy" /><category term="adb" /><category term="homelab" /><category term="linux" /><category term="old-phone-reuse" /><summary type="html"><![CDATA[从一次 Proxmox VE 硬盘迁移时网口不通的救急经历说起，讲清楚 Android USB 网络共享的原理与在 Linux 上的配置方法，并整理旧安卓手机可以承担的其他角色：Termux 服务器、投屏调试、USB 摄像头、应急启动盘、串口终端、免 root 抓包等。]]></summary></entry><entry><title type="html">Ignis：把 Obsidian 变成真正的自托管网页应用</title><link href="https://blog.einverne.info/post/2026/08/ignis-obsidian-web-app.html" rel="alternate" type="text/html" title="Ignis：把 Obsidian 变成真正的自托管网页应用" /><published>2026-08-16T00:00:00-05:00</published><updated>2026-08-16T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/08/ignis-obsidian-web-app</id><content type="html" xml:base="https://blog.einverne.info/post/2026/08/ignis-obsidian-web-app.html"><![CDATA[<p>用 [[Obsidian]] 记笔记这么多年，有一个需求一直没有被很好地解决，那就是在浏览器里直接访问自己的笔记库。Obsidian 官方一直没有推出 Web 版本，过去想要在别人的电脑上、或者在不方便安装客户端的环境里查看和编辑笔记，要么依赖远程桌面这种笨重的方案，要么就只能把笔记渲染成<a href="https://blog.einverne.info/post/2024/06/quartz-obsidian-publish.html">静态网站</a>，牺牲掉编辑能力，或者通过 SSH 登录我的 macOS 通过命令行方式访问。最近我发现了一个叫 <a href="https://ignis.thiefling.com/">Ignis</a> 的开源项目，它的口号非常直接：Run Obsidian as a self-hosted web app. Not remote desktop, an actual web app。它不是远程桌面，而是让 Obsidian 真正跑在浏览器里，体验下来确实让我眼前一亮，这篇文章就来聊聊它。</p>

<p><img src="https://pic.einverne.info/images/2026-08-16-10-05-00-ignis-obsidian-web-app-cover.png" alt="Ignis 让 Obsidian 在浏览器中运行" /></p>

<h2 id="为什么浏览器访问-obsidian-一直是个难题">为什么浏览器访问 Obsidian 一直是个难题</h2>

<p>Obsidian 是一个基于 Electron 的桌面应用，它的编辑器、插件系统、文件访问都建立在 Electron 提供的 Node.js 能力之上，而浏览器出于安全考虑并不提供这些 API，这是官方迟迟没有 Web 版的根本原因。在 Ignis 出现之前，想远程访问自己的笔记库大致有几类办法。</p>

<p>第一类是远程桌面方案，比如用 KasmVNC 或者类似 linuxserver 的 Obsidian 容器镜像，把整个桌面版 Obsidian 的画面通过 VNC 串流到浏览器。这类方案功能上最完整，但体验很差，字体渲染模糊、剪贴板不通、延迟明显，在手机上更是几乎不可用。</p>

<p>第二类是发布类方案，比如 Obsidian Publish、[[Quartz]]、Flowershow 这些工具，把笔记库渲染成静态网站。它们适合对外分享，但本质上是只读的，无法在浏览器里编辑笔记，也用不了任何插件。</p>

<p>第三类是换用天生就是 Web 应用的笔记工具，比如 SiYuan、AFFiNE 之类，但这意味着放弃 Obsidian 的整个插件生态和已经养成的工作流，迁移成本太高。</p>

<p>Ignis 走的是第四条路：它是一个兼容层（compatibility shim），为 Obsidian 所依赖的 Electron API 提供了浏览器端的实现，让原版 Obsidian 的代码直接在浏览器里运行，笔记库则保存在服务器上。值得一提的是，Ignis 本身不包含也不分发任何 Obsidian 的代码和资源，Docker 容器在首次启动时会从 Obsidian 官方源下载程序本体。项目采用 AGPL-3.0 协议开源，作者还专门写了一份 LEGAL.md，援引欧盟软件指令中关于互操作性的条款说明合法性，并明确表示无意损害 Obsidian 官方的商业利益，这种认真程度在同类项目里并不多见。</p>

<h2 id="ignis-能做到什么">Ignis 能做到什么</h2>

<p>我最关心的当然是兼容性，毕竟一个残缺的 Obsidian 没有意义。实际情况比我预期的好很多，Obsidian 的核心功能基本都能用：编辑器、Canvas 白板、Bases 数据库视图、命令面板、右键菜单、主题和 CSS 片段都正常工作，绝大多数基于 Obsidian 插件 API 开发的社区插件也能直接加载。图谱视图、大纲这些功能在正确配置 HTTPS 之后也都可用，这一点后面讲部署时会展开。</p>

<p>在 Web 化之后，Ignis 还带来了一些桌面版没有的能力。它支持通过工具栏、右键菜单或者直接拖拽来上传文件到笔记库，也可以把单个文件或整个文件夹打包成 ZIP 下载下来。多仓库支持做得很完整，可以创建、切换、重命名、删除 vault，不同的浏览器标签页甚至可以打开不同的 vault。多个标签页之间通过 WebSocket 实时同步，在一个标签页里的编辑会在一秒内出现在另一个标签页中。另外还有两个很实用的 URL 参数：<code class="language-plaintext highlighter-rouge">?workspace=</code> 可以在独立标签页中打开某个保存好的工作区布局，<code class="language-plaintext highlighter-rouge">?file=</code> 可以通过 URL 直接打开某篇笔记，这让 Obsidian 的笔记第一次拥有了可以分享给自己其他设备的链接。小屏幕设备上 Ignis 会切换到移动端 UI，手机浏览器里的体验接近 Obsidian 移动客户端。</p>

<table>
  <tbody>
    <tr>
      <td>同步方面，官方的 Obsidian Sync 可以在登录的标签页里正常工作，Ignis 还提供了服务端的 Headless Sync，即使浏览器标签页全部关闭，服务器也能继续在后台同步，这个设计解决了 Web 应用”关掉页面就停止工作”的天然缺陷。对于我这种用第三方方案同步的用户，obsidian-livesync 这类走 WebSocket 或 HTTP 的插件也能配置成功，只是要注意一些网络上的细节，后面避坑部分会提到。我在 [[2020-11-23-obsidian-sync-acrose-devices-solution</td>
      <td>我的 Obsidian 笔记跨设备同步方案]] 里梳理过各种同步方式，Ignis 相当于给这些方案又加了一个随时可用的 Web 入口。</td>
    </tr>
  </tbody>
</table>

<p>性能上作者也下了功夫。Ignis 用一次预压缩的 bootstrap 请求就把 vault 信息、元数据树、插件列表全部交付给浏览器，配合索引器预取（indexer pre-fetch）预热内容缓存，让 Obsidian 启动时的索引过程命中缓存而不是反复走网络。服务端用 LRU 缓存控制内存占用，默认 50MB，不会把整个笔记库都加载进内存，这些参数都可以在设置面板里调整。我的笔记库有几千个文件，加载速度完全在可接受范围内。</p>

<h2 id="用-docker-部署-ignis">用 Docker 部署 Ignis</h2>

<p>Ignis 的部署非常简单，官方提供了 Docker 镜像，一个 docker-compose 文件就能跑起来：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">ignis</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">nobbe/ignis:latest</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">8080:8080"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="c1"># 运行 id 命令查看自己的 uid/gid 并填入</span>
      <span class="pi">-</span> <span class="s">PUID=1000</span>
      <span class="pi">-</span> <span class="s">PGID=1000</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./vaults:/vaults</span>
      <span class="pi">-</span> <span class="s">./data:/app/data</span>
      <span class="pi">-</span> <span class="s">obsidian-app:/app/obsidian-app</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">obsidian-app</span><span class="pi">:</span>
</code></pre></div></div>

<p>保存为 <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> 之后执行 <code class="language-plaintext highlighter-rouge">docker compose up -d</code>，首次启动时容器会从官方源下载 Obsidian 和 obsidian-headless CLI，大概需要一两分钟，可以用 <code class="language-plaintext highlighter-rouge">docker compose logs -f</code> 观察进度。之后访问 <code class="language-plaintext highlighter-rouge">http://localhost:8080</code>，如果 <code class="language-plaintext highlighter-rouge">vaults</code> 目录下已经有笔记库会自动加载，否则会打开 vault 管理器引导创建第一个。</p>

<p>有几个部署细节值得注意。PUID 和 PGID 要和宿主机用户匹配，用 <code class="language-plaintext highlighter-rouge">id</code> 命令查一下自己的 uid 和 gid 填进去，否则 Ignis 写入的文件归属会出问题。如果笔记库放在 NAS 挂载或者 NFS 上，可以直接把外部目录挂载到 <code class="language-plaintext highlighter-rouge">/vaults</code> 下面的子目录；对于 rclone mount、FUSE、NFS、SMB 这类较慢的文件系统，还可以设置 <code class="language-plaintext highlighter-rouge">WRITE_COALESCE_MS</code> 环境变量开启写入合并去抖，减少频繁的小写入。</p>

<p>我自己的做法是把 Ignis 指向已有的同步目录，这样桌面版 Obsidian、手机客户端和 Ignis 操作的是同一份数据，Ignis 只是多出来的一个访问入口，不需要改变原有的同步链路。</p>

<h2 id="远程访问与安全最重要的避坑点">远程访问与安全：最重要的避坑点</h2>

<p>这一部分是使用 Ignis 之前必须搞清楚的，官方文档也用了醒目的警告来强调。核心有两点：Ignis 没有内置任何身份验证，以及浏览器的安全上下文（secure context）要求。</p>

<p>先说安全上下文。Obsidian 依赖的一些浏览器 API，比如加密和剪贴板相关的接口，只在 HTTPS 或者 localhost 环境下可用。所以如果你通过 <code class="language-plaintext highlighter-rouge">http://192.168.1.10:8080</code> 这样的局域网地址裸访问 Ignis，会发现图谱视图、大纲、Sync 等一系列功能默默失效，这不是 bug，而是浏览器的安全策略。解决办法有两类：正经的做法是在前面加一层 TLS，用 Caddy、nginx 或 Traefik 做反向代理（官方 examples 目录里有现成配置），或者用 <code class="language-plaintext highlighter-rouge">tailscale serve</code>、Cloudflare Tunnel 这类免证书管理的方案；偷懒的做法是在每个客户端浏览器里把 Ignis 的地址加入安全源白名单，Chromium 系浏览器在 <code class="language-plaintext highlighter-rouge">chrome://flags/#unsafely-treat-insecure-origin-as-secure</code> 设置，但这种方式只适合局域网，Safari 没有对应选项只能上 TLS。</p>

<p>再说身份验证。Ignis 默认监听纯 HTTP 且没有登录机制，任何能访问到这个端口的人都可以读写你的整个笔记库。所以绝对不要把 Ignis 直接暴露到公网。如果需要在外网访问，务必在前面加一层认证：反向代理的 Basic Auth 是最简单的，Authelia、Authentik、OAuth2 Proxy 这类 SSO 方案更完善，也可以用 Cloudflare Access 配合 Tunnel，或者干脆走 Tailscale、WireGuard 这样的 VPN 只在私有网络里访问。官方 examples 里提供了两套完整的 Caddy 配置，分别对应 Basic Auth 和 Authelia，可以直接拿来用。路线图里提到未来会支持内置认证和多用户 OIDC，但在那之前，认证完全是自己的责任。</p>

<p>我个人的建议是家庭网络内用 <code class="language-plaintext highlighter-rouge">tailscale serve</code> 一条命令解决 HTTPS 和访问控制两个问题，既不用管证书，也天然只有自己的设备能访问，是最省心的组合。</p>

<p>还有一个容易踩的坑是第三方同步插件的连通性。出于防止恶意网络扫描的考虑，Ignis 服务端默认拒绝中继指向私有地址、回环地址的 HTTP 请求，所以如果你的 CouchDB 或者其他同步服务器跑在局域网或同一台 Docker 主机上，需要通过 <code class="language-plaintext highlighter-rouge">PROXY_ALLOW_PRIVATE_HOSTS</code> 环境变量显式放行对应的 IP 或 CIDR（注意只接受 IP 不接受主机名），或者在设置里配置 direct-fetch 让浏览器直连（这要求同步服务器开启 CORS）。走 WebSocket 的同步插件则是浏览器直连，当 Ignis 本身是 HTTPS 时，浏览器会拒绝明文的 <code class="language-plaintext highlighter-rouge">ws://</code> 连接，同步服务器也需要提供 <code class="language-plaintext highlighter-rouge">wss://</code>，用受信任的证书或者同样套一层 <code class="language-plaintext highlighter-rouge">tailscale serve</code> 就能解决。</p>

<h2 id="限制与不完美的地方">限制与不完美的地方</h2>

<p>把 Electron 应用塞进浏览器不可能没有代价，有些限制需要提前知晓。最主要的是需要 Node 原生模块或 <code class="language-plaintext highlighter-rouge">child_process</code> 的插件无法加载，比如依赖本地执行命令的插件（典型如调用本地 Git 二进制、执行 shell 脚本的那一类）在 Ignis 里是跑不起来的，官方文档维护了一个插件兼容性页面，重度依赖某个插件的话建议先去查一下。</p>

<p>另一个需要留意的是密钥存储。桌面版 Obsidian 的插件可以用 Electron 的 safeStorage 借助操作系统加密敏感数据，浏览器没有等价能力，所以 Ignis 里插件存储的 API key 之类的秘密目前是明文保存的，服务端加密在计划中但尚未实现。在共享或安全性存疑的服务器上部署时，这一点要纳入考虑。</p>

<p>还有一些小的差异：浏览器无法弹出真正的本地文件选择器，所以像 Importer 这类插件导入文件要分两步操作，先选择文件暂存再重新执行动作；拼写检查语言跟随浏览器设置而不是应用内设置；依赖 Electron 菜单 API 的原生菜单选项被禁用。这些都属于可以接受的妥协。</p>

<p>最后要提醒的是，Ignis 还是一个很年轻的项目，虽然作者自己已经把它当作日常笔记工具在用，GitHub 上也已经收获了超过 1200 个 star，但处于活跃开发阶段意味着可能遇到未记录的问题。好在它只是数据的一个访问层，笔记本体始终是磁盘上的 Markdown 文件，就算 Ignis 出问题，数据本身也不会受影响，这也是我敢直接把它指向主力笔记库的原因。当然，任何时候都不要忘了备份。</p>

<h2 id="最后">最后</h2>

<p>Ignis 解决的是一个存在了很多年的真实痛点：Obsidian 的本地优先哲学和随时随地访问之间的矛盾。此前的答案要么是体验糟糕的远程桌面，要么是丧失编辑能力的静态发布，而 Ignis 用兼容层的思路给出了第三种答案，让你在浏览器里得到一个接近原生的、插件可用的、可编辑的 Obsidian，而数据依然完整地躺在自己服务器的文件系统里。</p>

<p>对我来说，它最大的价值是让 Obsidian 的访问入口从”装了客户端的设备”扩展到了”任何一个有浏览器的地方”，配合 Tailscale 之后，在公司的电脑、朋友的电脑、甚至 iPad 的浏览器里打开自己的笔记库都只是一个 URL 的事情。如果你也是 Obsidian 的自托管爱好者，手边有一台跑着 Docker 的小主机或 NAS，非常值得花十几分钟把 Ignis 跑起来体验一下。项目的 <a href="https://github.com/Nystik-gh/ignis">GitHub 仓库</a> 和<a href="https://ignis.thiefling.com/docs/">官方文档</a>都写得相当清楚，部署前把安全章节读一遍，就可以放心使用了。</p>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="产品体验" /><category term="obsidian" /><category term="ignis" /><category term="self-hosted" /><category term="docker" /><category term="knowledge-management" /><summary type="html"><![CDATA[Ignis 通过为 Electron API 提供浏览器兼容层，让 Obsidian 以真正 Web 应用的形式自托管运行。本文介绍 Ignis 的工作原理、Docker 部署步骤、远程访问与安全配置，以及实际使用中的限制与避坑经验。]]></summary></entry><entry><title type="html">卧底厨神观后感</title><link href="https://blog.einverne.info/post/2026/08/undercover-chef.html" rel="alternate" type="text/html" title="卧底厨神观后感" /><published>2026-08-11T00:00:00-05:00</published><updated>2026-08-11T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/08/undercover-chef</id><content type="html" xml:base="https://blog.einverne.info/post/2026/08/undercover-chef.html"><![CDATA[<p>2026 年山之日这个假期外面下着雨，没有出行的计划，索性看起了放到了待看列表的《卧底厨神》，因为之前看过《黑白大厨》知道了权主厨，郑主厨，本来没有对这部综艺抱有太大的期待，只是想在一个休息日放松心情看一篇轻松的综艺，但没想到完全被这三位主厨圈粉了。</p>

<h2 id="一档反着来的美食综艺">一档反着来的美食综艺</h2>

<p>看完 [[卧底厨神]]，我发现自己记住的居然不是任何一道菜，而是几个人在陌生后厨里手忙脚乱的样子。对一档美食综艺来说，这挺反常的，但我仔细想了想卧底厨神或许更适合将其归类为真人观察类综艺。</p>

<p>自从 [[黑白大厨]] 火了之后，美食综艺几乎被”竞技”二字绑架了：切磋、对决、淘汰、排名，厨师们像格斗选手一样被推上擂台。但卧底厨神聪明就聪明在，在这里我们看不到对决，淘汰，而是真实的餐厅后厨，真实的把每一道菜端到客人面前：三位在韩国功成名就的主厨，隐藏姓名和头衔，伪装成完全不会做菜的菜鸟，潜入意大利帕尔马、那不勒斯和中国成都的餐厅后厨，从洗碗、择菜、擦灶台、切菜做起。</p>

<p>他们的任务只有一个：在 5 天之内，不暴露自己厨师的身份，却要赢得当地主厨的认可，把自己的招牌菜写进餐厅菜单。</p>

<p>这个设定微妙在它的双重矛盾：会做菜的人要装作不会做菜，本身就是高难度表演；而装作不会的同时，又得”恰到好处”地露出潜力，否则永远轮不到你碰灶台。知道的是卧底综艺，不知道的还以为在看演技考核。</p>

<h2 id="三个人三条线">三个人，三条线</h2>

<p>三位主厨各自带着不同的故事线。Sam Kim 是韩国意大利菜的代表人物，去了启发他料理生涯的帕尔马，伪装成转行的农夫；权圣晙顶着黑白大厨首季冠军”那不勒斯黑手党”的名号，回到了绰号的来源地那不勒斯；[[郑智善]] 作为韩国首位女性中餐主厨，伪装成拳击手、去了川菜的大本营成都。这三位主厨的人物刻画，每一个都非常生动。我们可以从节目组叙事的娓娓道来中去熟悉每一个主厨的人物生平。</p>

<p>权圣晙主厨是傲娇的，有点拽，但拽得很可爱。日常生活里不拘小节，演播室里主持人问”想改变第一天的什么内容”，权主厨的回答是想改一下后采时的发型，此时再去看权主厨的头发时，我当场笑死。但反差也恰恰在这里：日常呆萌又安静的一个人，一进厨房，那种属于厨师的专注立刻就出来了。</p>

<p>郑智善主厨的反差则在演播室和后厨之间。演播室里的她是强势果断的领导者气质——毕竟是拥有多家餐厅的女老板；但在成都当菜鸟的时候，你能看到她细腻的一面：观察力很强，会照顾周围人的情绪，外刚内柔。她对自己的要求也高到近乎苛刻：厨房里大家都坐着休息，但从来没看到郑主厨坐下过，她不允许自己的餐厅里听歌。从她的自述里也能感受到一路走来的艰辛——作为一名女性主厨，长期得不到行业内的认可，但她依然靠着实力、经验和努力获得了今天的成就。也正因为这样，当她在成都被地道川菜和沉得多的中式炒锅接连挫败、肉眼可见地陷入自我怀疑时（据 PD 事后采访说，制作组当时甚至担心第二天还能不能拍下去），这条线才格外有张力。后面怎么走，就不剧透了，留给你自己看。</p>

<p>而我最喜欢的，是节目中出现的第一位主厨 Sam Kim（샘킴）的故事。明明已经是多家餐厅的大老板，管理着 20 多名员工，但到了异国他乡，依然是那个胆小、内向、不会主动开口说话的人。性格温和，但专业能力藏不住。经典的搞笑段落也大多是他贡献的——意大利饺子的形状连着做错了 3 次。虽然犯了这么多新人的错，但依然能看到 Sam Kim 主厨的认真，上班前学好意大利语，背下来再上班，看他这条线的时候，最容易让我想起初入社会的菜鸟。这样一位温柔害羞的大叔，谁能不喜欢。</p>

<h2 id="它真正好看的地方">它真正好看的地方</h2>

<p>导演 [[洪镇珠]] 在采访里说过一句我很认同的话：这个节目从来不是料理综艺，更接近一部”人类纪录片”。节目组没有为了效果放水，餐厅也没有配合演出——于是镜头里留下的是货真价实的挫败、鸡同鸭讲的语言障碍、深夜才结束的营业，以及在这一切之上依然生长出来的感情。正因为这 5 天的关系不是演出来的，后面每一个情绪节点才都立得住。</p>

<p>观众也用遥控器投了票：播出期间收视率一路涨到 6.2%，是首播的 3 倍，连续 10 周同时段第一。大家早就看腻了被剧本保护起来的成功。</p>

<p>另一个值得说的点是”重走来时路”这个母题。三位主厨都不是空降的天才，都当过厨房最底层的小工。节目让他们在职业生涯的顶点重新体验一次底层——Sam Kim 每天早上背意大利语，琢磨怎么跟前辈搭话；权圣晙营业到半夜也一声不吭地干活。这种东西，综艺剧本写不出来。</p>

<h2 id="这档节目是怎么被策划出来的">这档节目是怎么被策划出来的</h2>

<p>好节目不是偶然。写这篇观后感的时候，我顺手翻了 [[洪镇珠]] PD 和尹阿尔音编剧的收官采访，几个幕后细节很值得记下来。</p>

<p>企划始于 2025 年末制作团队构思新节目的阶段。洪镇珠是入行 11 年的 PD，此前一直做 [[帐篷外是欧洲]] 系列，这是其第一次从零企划一档新节目。出发点是一个判断：观众的眼光越来越高，必须做”真的”才能让人满足；而且要做一档能让人”过度沉浸”的节目——这种沉浸不只属于观众，也属于出演者。用洪镇珠的话说，出演者要沉浸到忘记自己在拍摄，才会讲出真正的故事。卧底潜入的设定就是从这个想法里长出来的。</p>

<p>真正难的是落地。给主厨们编的掩护故事是”拍摄一部隐退名人挑战第二人生的纪录片”；而选餐厅要同时考虑味道、员工的性格倾向、顾客构成、厨房的运作体系，制作组形容那个阶段是”跑断腿、磨破嘴”。餐厅方面起初并不友好——陌生人跑来说要拍摄，换谁都会起疑，成都餐厅的家人和帕尔马餐厅都疑虑重重，信任是一家一家磨出来的。</p>

<p>选角的标准也有意思：最看重的不是名气，而是”对这个节目有多大兴趣”。制作组在企划初期从隐世高手到新人厨师广泛请教，最终选中的三位，都是对企划本身充满好奇、并且能把当年做厨房小辈的经历讲得生动有趣的人。事后看，这个标准选出来的人确实撑起了节目——对企划没有热情的人，很难心甘情愿地在异国后厨打 5 天零工。</p>

<p>编剧尹阿尔音的说法则从另一个角度印证了节目的定位：从一开始就没把它当料理节目做，关注的是主厨们的工作方式，以及在升级过程中与当地员工建立的关系。所以那种”人类纪录片”的质感不是剪出来的，是策划阶段就定下来的。</p>

<h2 id="想起初入社会的自己">想起初入社会的自己</h2>

<p>看 [[黑白大厨]] 的时候，作为观众我只是一味地觉得比赛、竞技精彩；但在这档综艺里，虽然讲的是厨神们如何从底层做起，却无比”真实”。语言不通、反复犯错、在厨房里不知所措——这些画面更让我想起自己初入社会时的情形：看眼色，讲人情，还会在一些重要的时刻犯错。但身边的所有人都非常友好、善良，可以包容菜鸟的犯错；也可以看到当他们被主厨认可之后的那种成就感。</p>

<p>我想，这三位主厨以他们在韩国的位置，本可能不会参加这么辛苦的节目——因为我们在节目里是能看到那份辛苦的。从他们自己的回忆中，也可以感受到过去为了学习料理付出的汗水。但我恰恰是被这些真实存在的人感动了。短短几天时间内建立起来的情感，综艺中所表现出来的辛酸与情义，以至于让我都忘记了他们最终的目标，更愿意去看他们和这群普通人最后的结局。</p>

<h2 id="观看建议">观看建议</h2>

<p>如果你被上面的描述勾起了兴趣，几个实用信息：</p>

<ul>
  <li>平台：韩国本土在 [[tvN]] 和 [[TVING]]，国际版在 [[HBO Max]] 上架，译名”卧底厨神”。注意国际版的集数剪辑和 tvN 的 10 集版本不一致，更新也滞后于韩国播出</li>
  <li>如果你是因为黑白大厨认识权圣晙的，这档节目会让你看到竞技场之外完全不同的他</li>
  <li>不要抱着看厨艺教学的心态来，这里几乎没有炫技镜头；它好看的部分全在人和人之间</li>
  <li>衍生篇已经官宣，帕尔马、那不勒斯、成都的当地厨房前辈们将反向造访韩国，预计 2026 年内播出，可以先把正篇补完等着</li>
</ul>

<h2 id="最后">最后</h2>

<p>卧底厨神给我的最大启发其实跟做菜无关：一个领域的顶尖高手，把头衔摘掉之后还剩下什么？节目给出的答案是——剩下的是习惯。是每天提前到岗的习惯，是一遍一遍反复擦拭的灶台，是被否定之后第二天照样出现的习惯。头衔证明不了这些，只有把人扔回底层才能看见。</p>

<p>至于三个人最后到底有没有把菜写进菜单，这里就不说了。反正看到后来，连我自己都忘了这档节目原本的任务是什么——能让观众忘掉规则、只记得人的综艺，不多见。期待衍生篇。</p>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="产品体验" /><category term="korean-variety-show" /><category term="food-show" /><category term="reality-show" /><category term="tv-review" /><category term="undercover-chef" /><category term="tvn" /><category term="hbo-max" /><category term="cooking" /><category term="chef" /><summary type="html"><![CDATA[tvN 真人秀卧底厨神的观后评论：三位功成名就的韩国主厨隐藏身份潜入海外餐厅后厨，从洗碗备菜做起。一档靠真实打动人的真人观察综艺。]]></summary></entry><entry><title type="html">mise 的 minimum_release_age 给新版本加一道冷静期的供应链安全机制</title><link href="https://blog.einverne.info/post/2026/08/mise-minimum-release-age.html" rel="alternate" type="text/html" title="mise 的 minimum_release_age 给新版本加一道冷静期的供应链安全机制" /><published>2026-08-04T00:00:00-05:00</published><updated>2026-08-04T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/08/mise-minimum-release-age</id><content type="html" xml:base="https://blog.einverne.info/post/2026/08/mise-minimum-release-age.html"><![CDATA[<p><img src="https://pic.einverne.info/images/2026-08-04-10-00-00-mise-minimum-release-age.png" alt="新版本在时间闸门前等待放行" /></p>

<p>今天在终端里跑 <code class="language-plaintext highlighter-rouge">mise upgrade</code> 想要升级 herdr 的时候，突然看到一条以前没见过的 WARN 提示，「mise WARN  1 newer herdr release hidden by minimum_release_age」，大意是有一个更新的版本存在，但因为发布时间太短，被 <code class="language-plaintext highlighter-rouge">minimum_release_age</code> 设置过滤掉了，暂时不会被安装。最后去看了 [[mise]] 的更新日志才发现，这是 mise 在 2026 年新引入的一个供应链安全机制，而且从 v2026.6.2 开始默认对所有人生效。这篇文章就把这个机制的来龙去脉、警告的含义以及怎么配置讲清楚。</p>

<h2 id="minimum_release_age-是什么">minimum_release_age 是什么</h2>

<p>简单说，<code class="language-plaintext highlighter-rouge">minimum_release_age</code> 的作用是：一个新版本发布之后，必须先等待一段时间（默认 24 小时），mise 才会认为它”可用”。在这个时间窗口内，即使上游已经发布了新版本，<code class="language-plaintext highlighter-rouge">mise install</code>、<code class="language-plaintext highlighter-rouge">mise upgrade</code>、<code class="language-plaintext highlighter-rouge">mise latest</code> 这些命令也会当它不存在，继续解析到上一个满足时间要求的版本。</p>

<p>这个设计针对的是近几年愈演愈烈的软件供应链攻击。典型的攻击场景是这样的：攻击者拿到某个流行包的发布权限（钓鱼拿到 maintainer 的 npm token、CI 配置泄露等等），发布一个带恶意代码的新版本，然后等着全世界的自动更新工具在几小时内把它拉下来。这类被投毒的版本通常存活时间很短，社区、安全厂商和 registry 官方往往在几小时到一两天内就会发现并下架。所以”等一等”本身就是一种非常朴素但有效的防御——只要你不做第一批吃螃蟹的人，绝大多数投毒版本在到达你机器之前就已经被清理掉了。</p>

<p>这个思路并不是 mise 首创。[[Renovate]] 很早就有 <code class="language-plaintext highlighter-rouge">minimumReleaseAge</code> 配置，用来推迟自动升级 PR 的创建；[[pnpm]] 也在 10.16 之后加入了同名的 <code class="language-plaintext highlighter-rouge">minimumReleaseAge</code> 设置，安装依赖时跳过太新的版本。mise 做的事情是把同样的理念搬到了开发工具版本管理这一层——你通过 mise 安装的 node、go、terraform，以及各种通过 aqua、npm、pipx 后端装的 CLI 工具，统一套上这道时间闸门。</p>

<h2 id="那条-warn-警告到底在说什么">那条 WARN 警告到底在说什么</h2>

<p>回到开头那条警告。当你运行 <code class="language-plaintext highlighter-rouge">mise upgrade</code> 或者 <code class="language-plaintext highlighter-rouge">mise outdated</code> 之类的命令时，mise 会去查询各个工具的远程版本列表。如果它发现存在比当前已安装版本更新的版本，但那个版本的发布时间还没超过 <code class="language-plaintext highlighter-rouge">minimum_release_age</code> 设定的时长，就会打印一条 WARN，告诉你有 N 个更新版本因为太新而被暂时过滤掉了。</p>

<p>这里要强调的是，这不是错误，也不需要你做任何事。mise 会继续使用满足时间要求的最新版本，被过滤的那个版本会在时间窗口过去之后（默认发布满 24 小时）自动变为可用，下次再跑 <code class="language-plaintext highlighter-rouge">mise upgrade</code> 就会正常升级上去。这条警告存在的意义只是告知，避免你困惑”明明上游发新版了为什么 mise 装不到”。</p>

<p>从 v2026.6.2 开始，mise 为所有能提供发布时间戳的后端内置了这个 24 小时的默认延迟，包括 core（node、go 这些核心工具）、aqua、github、cargo、go、npm、pipx 等。也就是说即使你从来没在配置里写过 <code class="language-plaintext highlighter-rouge">minimum_release_age</code>，这个机制也已经在保护你了，这也是为什么很多人像我一样是先看到警告、再反过来查文档的。</p>

<h2 id="几个容易误解的细节">几个容易误解的细节</h2>

<p>在翻 mise 文档和源码的过程中，我发现这个机制有几个行为细节值得单独说清楚，不然很容易产生错误的预期。</p>

<p>第一，它只影响模糊版本解析，不影响显式 pin 的版本。所谓模糊版本，就是 <code class="language-plaintext highlighter-rouge">latest</code>、<code class="language-plaintext highlighter-rouge">node@20</code>、<code class="language-plaintext highlighter-rouge">terraform@1</code> 这种需要 mise 去解析”到底是哪个具体版本”的写法。如果你在 <code class="language-plaintext highlighter-rouge">mise.toml</code> 里明确写死了 <code class="language-plaintext highlighter-rouge">node = "22.14.0"</code>，那不管这个版本是不是一小时前刚发布的，mise 都会照装不误。这个设计是合理的——显式 pin 意味着你明确知道自己要什么，工具不应该替你做主；而模糊解析场景下你把选择权交给了 mise，它就有责任帮你过滤掉风险窗口内的版本。</p>

<p>第二，过滤能力取决于后端能不能提供发布时间戳。core、aqua、github、cargo、go、npm、pipx 这些后端能拿到每个版本的发布时间，过滤就能生效；拿不到时间戳的版本会被默认放行，不会因为”无法判断”而被误伤。</p>

<p>第三，传递依赖的覆盖范围目前还有限。对于 <code class="language-plaintext highlighter-rouge">npm:</code> 和 <code class="language-plaintext highlighter-rouge">pipx:</code> 后端安装的工具，mise 会把时间窗口透传给底层的包管理器，让传递依赖也遵守同样的规则；其他后端目前只过滤顶层工具本身的版本。如果你的威胁模型主要担心 npm 生态的依赖投毒，这一点算是个不小的加分项。</p>

<h2 id="配置方式与实践建议">配置方式与实践建议</h2>

<p>默认的 24 小时对大多数人来说是个不错的平衡点，但 mise 提供了完整的配置手段，可以按自己的风险偏好调整。</p>

<p>全局调整时间窗口，在 <code class="language-plaintext highlighter-rouge">~/.config/mise/config.toml</code> 或项目的 <code class="language-plaintext highlighter-rouge">mise.toml</code> 里设置：</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[settings]</span>
<span class="py">minimum_release_age</span> <span class="p">=</span> <span class="s">"7d"</span>   <span class="c"># 只安装发布超过 7 天的版本</span>
</code></pre></div></div>

<p>时长支持 <code class="language-plaintext highlighter-rouge">24h</code>、<code class="language-plaintext highlighter-rouge">7d</code>、<code class="language-plaintext highlighter-rouge">1y</code> 这种相对写法。如果你所在的团队对安全要求比较高，把这个值调到 3 到 7 天是常见做法。</p>

<p>有些工具的更新是时间敏感的，比如漏洞扫描器 trivy，它的新版本往往携带最新的漏洞库，晚装一天反而降低安全性。这种情况可以按工具覆盖全局设置：</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[settings]</span>
<span class="py">minimum_release_age</span> <span class="p">=</span> <span class="s">"7d"</span>

<span class="nn">[tools.trivy]</span>
<span class="py">version</span> <span class="p">=</span> <span class="s">"latest"</span>
<span class="py">minimum_release_age</span> <span class="p">=</span> <span class="s">"1d"</span>
</code></pre></div></div>

<p>也可以用排除列表把特定工具或整个后端排除在全局策略之外，支持通配符：</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[settings]</span>
<span class="py">minimum_release_age</span> <span class="p">=</span> <span class="s">"7d"</span>
<span class="py">minimum_release_age_excludes</span> <span class="p">=</span> <span class="p">[</span><span class="s">"trivy"</span><span class="p">,</span> <span class="s">"npm:*"</span><span class="p">]</span>
</code></pre></div></div>

<p>需要注意优先级顺序：命令行的 <code class="language-plaintext highlighter-rouge">--minimum-release-age</code> 参数最高，其次是按工具的设置，最后才是全局设置。被排除的工具仍然会尊重它自己的 per-tool 设置和命令行参数。</p>

<p>如果某次你确实需要立刻装上一个刚发布的版本（比如上游刚修了一个影响你的 bug），不用改配置文件，临时用命令行参数绕过即可：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mise upgrade node <span class="nt">--minimum-release-age</span> 0
mise latest node <span class="nt">--minimum-release-age</span> 2024-01-01   <span class="c"># 也支持绝对日期</span>
</code></pre></div></div>

<p>反过来，如果你完全不想要这个机制，在全局配置里把 <code class="language-plaintext highlighter-rouge">minimum_release_age</code> 设为 <code class="language-plaintext highlighter-rouge">0</code> 就可以彻底关掉。不过在关掉之前建议想清楚，这个默认值的存在几乎没有日常成本——你感知到的无非就是新版本晚一天到手——换来的却是躲开绝大多数投毒版本存活窗口的保护。</p>

<p>另外值得一提的是它和 <code class="language-plaintext highlighter-rouge">mise.lock</code> 的配合。lockfile 保证的是”团队所有人装到的是同一个被验证过的版本”，<code class="language-plaintext highlighter-rouge">minimum_release_age</code> 保证的是”解析新版本时不会撞上刚出炉的风险版本”，两者是互补关系而不是替代关系。对于有 CI 环境的项目，lockfile 加上默认的时间窗口，基本就把工具链这一层的供应链风险控制在了一个比较舒服的水平。</p>

<h2 id="最后">最后</h2>

<p><code class="language-plaintext highlighter-rouge">minimum_release_age</code> 是那种典型的”好的默认值”设计：机制本身极其简单，就是给新版本加一道冷静期，但它选择了默认开启，让所有 mise 用户在无感知的情况下获得了对供应链投毒攻击的基础免疫。从 npm 的 event-stream 到近几年层出不穷的 maintainer 账号劫持事件，这类攻击的共同特点就是投毒版本存活时间短、传播依赖自动更新，而”等 24 小时”恰好精准打在这两个特点上。</p>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="经验总结" /><category term="mise" /><category term="supply-chain-security" /><category term="version-manager" /><category term="devtools" /><category term="npm" /><category term="security" /><category term="cli" /><summary type="html"><![CDATA[mise 从 v2026.6.2 开始为所有能提供发布时间戳的后端默认启用 minimum_release_age，新发布的工具版本要等 24 小时才会被安装。这篇文章讲清楚这条 WARN 警告的含义、这个机制的来龙去脉、它与 Renovate 和 pnpm 同类功能的对比，以及全局配置、按工具覆盖、排除列表和临时绕过的具体用法。]]></summary></entry><entry><title type="html">chezmoi Go 语言编写的跨平台 dotfiles 管理工具</title><link href="https://blog.einverne.info/post/2026/07/chezmoi.html" rel="alternate" type="text/html" title="chezmoi Go 语言编写的跨平台 dotfiles 管理工具" /><published>2026-07-29T00:00:00-05:00</published><updated>2026-07-29T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/07/chezmoi</id><content type="html" xml:base="https://blog.einverne.info/post/2026/07/chezmoi.html"><![CDATA[<p><a href="https://www.chezmoi.io/">chezmoi</a> 是一个用 [[Go]] 编写的开源 dotfiles 管理工具，帮助开发者在多台机器上统一管理个人配置文件（如 <code class="language-plaintext highlighter-rouge">~/.gitconfig</code>、<code class="language-plaintext highlighter-rouge">~/.zshrc</code>、<code class="language-plaintext highlighter-rouge">~/.vimrc</code> 等）。</p>

<p>发音为 <code class="language-plaintext highlighter-rouge">/ʃeɪ mwa/ (shay-moi)</code>，是法语”在我家”的意思，名称寓意让每台机器都拥有”家的感觉”。</p>

<p>区别于 [[GNU Stow]] 这类基于符号链接的工具，chezmoi 将配置文件复制到目标位置（而非创建 symlink），并通过 Go 模板语言实现机器之间的差异化配置，同时内置密钥管理和加密能力。</p>

<h2 id="chezmoi-解决的问题">chezmoi 解决的问题</h2>

<p>传统 dotfiles 管理方式（直接用 git 仓库 + symlink）有几个痛点：</p>

<p>一是密钥安全问题。git 不适合存储密码、API 密钥等敏感内容，一旦误提交到公开仓库就可能立即造成泄露。</p>

<p>二是多机差异问题。工作机和个人机的 git 邮箱不同、macOS 和 Linux 的配置路径不同，维护多套配置文件繁琐。</p>

<p>三是跨平台问题。[[GNU Stow]] 仅支持 Unix 系统，且依赖 symlink，在 Windows 上无法正常工作。</p>

<p>chezmoi 通过模板化、加密和声明式管理解决了上述问题。</p>

<h2 id="核心功能">核心功能</h2>

<p>模板化配置是 chezmoi 的核心能力。基于 [[Go]] 标准库的 <code class="language-plaintext highlighter-rouge">text/template</code>，同一份配置文件可以根据主机名、操作系统、环境变量等条件动态渲染出不同内容。例如：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[user]
    name = 
    email = work@company.compersonal@gmail.com
</code></pre></div></div>

<p>加密与密钥管理方面，chezmoi 原生支持 age、gpg、git-crypt 和 transcrypt 对文件加密，也支持从 1Password、Bitwarden、Vault、KeePassXC 等密码管理器中动态读取密钥，敏感内容永远不会以明文形式出现在 git 仓库中。</p>

<p>差异化忽略支持对不同机器忽略不同文件，例如 VPN 配置只在工作机上管理，而不出现在个人机上。</p>

<p>脚本执行支持在 <code class="language-plaintext highlighter-rouge">apply</code> 时运行安装脚本，并通过 <code class="language-plaintext highlighter-rouge">onchange_</code> 前缀实现”只在配置变更时重新执行”，避免每次 apply 都重跑 Homebrew 安装等耗时操作。</p>

<p>干运行与 diff 模式支持 <code class="language-plaintext highlighter-rouge">chezmoi diff</code> 查看将要应用的变更，<code class="language-plaintext highlighter-rouge">chezmoi apply --dry-run</code> 模拟执行，对新机器上线特别有用。</p>

<h2 id="工作原理">工作原理</h2>

<p>chezmoi 维护一个”源目录”（默认在 <code class="language-plaintext highlighter-rouge">~/.local/share/chezmoi</code>），其中的文件名通过前缀约定来描述意图：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">dot_</code> 前缀：对应目标路径中的 <code class="language-plaintext highlighter-rouge">.</code>，例如 <code class="language-plaintext highlighter-rouge">dot_zshrc</code> → <code class="language-plaintext highlighter-rouge">~/.zshrc</code></li>
  <li><code class="language-plaintext highlighter-rouge">private_</code> 前缀：文件应设置为仅所有者可读（权限 0600）</li>
  <li><code class="language-plaintext highlighter-rouge">executable_</code> 前缀：文件应具有可执行权限</li>
  <li><code class="language-plaintext highlighter-rouge">encrypted_</code> 前缀：文件内容已加密存储</li>
  <li><code class="language-plaintext highlighter-rouge">once_</code> 前缀：脚本只执行一次</li>
  <li><code class="language-plaintext highlighter-rouge">onchange_</code> 前缀：脚本在内容变更时才重新执行</li>
</ul>

<p>执行 <code class="language-plaintext highlighter-rouge">chezmoi apply</code> 时，chezmoi 读取源目录中的文件，渲染模板，解密加密文件，最后将结果写入 Home 目录对应位置。</p>

<h2 id="安装与初始化">安装与初始化</h2>

<p>一行命令安装并初始化（将 <code class="language-plaintext highlighter-rouge">GITHUB_USERNAME</code> 替换为你的 GitHub 用户名）：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sh <span class="nt">-c</span> <span class="s2">"</span><span class="si">$(</span>curl <span class="nt">-fsLS</span> chezmoi.io/get<span class="si">)</span><span class="s2">"</span> <span class="nt">--</span> init <span class="nt">--apply</span> GITHUB_USERNAME
</code></pre></div></div>

<p>macOS 用 Homebrew 安装：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>chezmoi
</code></pre></div></div>

<p>Linux 各发行版也可通过包管理器安装，或直接下载二进制文件。</p>

<p>初始化一个新的 chezmoi 配置（不从 git 仓库拉取）：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chezmoi init
</code></pre></div></div>

<h2 id="日常使用">日常使用</h2>

<p>添加一个文件到 chezmoi 管理：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chezmoi add ~/.zshrc
chezmoi add ~/.gitconfig
</code></pre></div></div>

<p>进入 chezmoi 源目录（方便直接编辑）：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chezmoi <span class="nb">cd</span>
</code></pre></div></div>

<p>查看将要应用的变更：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chezmoi diff
</code></pre></div></div>

<p>将源目录中的配置应用到 Home 目录：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chezmoi apply
</code></pre></div></div>

<p>从 git 仓库拉取最新配置并应用：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chezmoi update
</code></pre></div></div>

<p>编辑某个已托管的文件后直接应用：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chezmoi edit ~/.zshrc <span class="nt">--apply</span>
</code></pre></div></div>

<h2 id="对比分析">对比分析</h2>

<table>
  <thead>
    <tr>
      <th>特性</th>
      <th>chezmoi</th>
      <th>[[GNU Stow]]</th>
      <th>YADM</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>实现方式</td>
      <td>文件复制 + 模板</td>
      <td>符号链接</td>
      <td>git 包装器</td>
    </tr>
    <tr>
      <td>模板支持</td>
      <td>内置 Go 模板</td>
      <td>无</td>
      <td>依赖外部工具（已停维）</td>
    </tr>
    <tr>
      <td>密钥管理</td>
      <td>内置多种加密</td>
      <td>无</td>
      <td>有限</td>
    </tr>
    <tr>
      <td>多 OS 支持</td>
      <td>优秀</td>
      <td>需手动处理</td>
      <td>基础</td>
    </tr>
    <tr>
      <td>Windows 支持</td>
      <td>支持</td>
      <td>不支持</td>
      <td>不支持</td>
    </tr>
    <tr>
      <td>停止使用成本</td>
      <td>低（文件即原文件）</td>
      <td>需删除 symlink</td>
      <td>低</td>
    </tr>
    <tr>
      <td>学习曲线</td>
      <td>中等</td>
      <td>极低</td>
      <td>低</td>
    </tr>
  </tbody>
</table>

<p>与 [[dotbot]] 相比，chezmoi 的模板能力更强，密钥管理更完善，但 dotbot 的 YAML 配置风格对习惯声明式配置的用户更直观。</p>

<h2 id="适用场景">适用场景</h2>

<p>同时使用多台机器（工作机、家用机、云服务器）的开发者最能受益于 chezmoi。</p>

<p>配置文件中含有敏感信息（API 密钥、私有地址等）需要安全管理的场景，chezmoi 的加密集成是最佳选择。</p>

<p>在 macOS、Linux 乃至 Windows 跨平台工作的开发者，可以用同一套源文件生成各平台的差异化配置。</p>

<p>对于想要在新机器上实现”一键还原工作环境”的工程师，chezmoi 配合 Homebrew Bundle 或 Nix 可以做到完整的环境复现。</p>

<h2 id="优势与挑战">优势与挑战</h2>

<p>优势方面，chezmoi 文档详细、社区活跃，内置功能几乎涵盖所有 dotfiles 管理场景，且单一二进制无外部依赖。由于使用文件复制而非 symlink，随时停止使用都不需要额外清理工作。</p>

<p>挑战方面，源目录中的文件名加了 <code class="language-plaintext highlighter-rouge">dot_</code> 等前缀，与实际路径不完全对应，初期会有一定认知成本。对于只管理少量配置文件、无跨机需求的用户，chezmoi 的功能可能显得过重，简单的 git bare repo 方式或许更合适。</p>

<h2 id="最后">最后</h2>

<p>chezmoi 是目前功能最完整、维护最积极的 dotfiles 管理工具之一，尤其适合有跨设备、跨平台需求或需要在配置中处理敏感信息的开发者。从 [[dotbot]] 或 [[GNU Stow]] 迁移到 chezmoi 有一定学习成本，但长期来看模板化和密钥管理带来的收益十分显著。</p>

<h2 id="相关链接">相关链接</h2>

<ul>
  <li><a href="https://www.chezmoi.io/user-guide/setup/">chezmoi 官方文档</a></li>
  <li><a href="https://www.chezmoi.io/why-use-chezmoi/">为什么选择 chezmoi</a></li>
  <li><a href="https://www.chezmoi.io/comparison-table/">工具对比表</a></li>
  <li><a href="https://github.com/twpayne/chezmoi">chezmoi GitHub 仓库</a></li>
  <li><a href="https://www.chezmoi.io/quick-start/">chezmoi Quick Start</a></li>
</ul>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="经验总结" /><category term="dotfiles" /><category term="configuration-management" /><category term="go" /><category term="cli" /><category term="productivity" /><category term="cross-platform" /><category term="secrets-management" /><category term="templating" /><category term="developer-tools" /><category term="open-source" /><summary type="html"><![CDATA[chezmoi 是一个用 Go 编写的跨平台 dotfiles 管理工具，支持模板化配置、密钥加密和多设备同步，发音为 /ʃeɪ mwa/ (shay-moi)。]]></summary></entry><entry><title type="html">Syncthing 升级 2.0 后同步卡在 Preparing to Sync 的解决方法</title><link href="https://blog.einverne.info/post/2026/07/syncthing-preparing-to-sync-after-upgrade.html" rel="alternate" type="text/html" title="Syncthing 升级 2.0 后同步卡在 Preparing to Sync 的解决方法" /><published>2026-07-07T00:00:00-05:00</published><updated>2026-07-07T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/07/syncthing-preparing-to-sync-after-upgrade</id><content type="html" xml:base="https://blog.einverne.info/post/2026/07/syncthing-preparing-to-sync-after-upgrade.html"><![CDATA[<p><img src="https://pic.einverne.info/images/syncthing-preparing-to-sync.png" alt="Syncthing 同步状态卡顿示意图" /></p>

<p>前几天例行 <code class="language-plaintext highlighter-rouge">brew upgrade</code> 之后，[[Syncthing]] 其中的某一个高频使用的同步文件夹就一直卡在 Preparing to Sync 状态，进度条纹丝不动，CPU 占用却莫名升高。我最初以为是网络问题或者对端设备没启动，结果检查一遍发现所有的其他 Syncthing 节点都正常，就是本机 macOS 这端不动弹。这个状态持续了将近一个小时，才让我意识到不对劲，开始认真排查。</p>

<h2 id="问题背景">问题背景</h2>

<p><a href="https://blog.einverne.info/post/2019/10/syncthing.html">Syncthing</a> 是一款开源的点对点文件同步工具，不依赖任何中心服务器，数据直接在你自己的设备之间流转。我用它同步多台设备上的工作目录和重要文件，已经稳定运行了6，7年，这次升级前一切都好好的。</p>

<p>这次触发问题的，是 [[Homebrew]] 将 Syncthing 从 1.x 版本升级到了 2.x 版本。Syncthing 2.0 是一次幅度相当大的版本跨越，包含了几项可能让用户措手不及的破坏性变更（breaking changes）。</p>

<h2 id="根本原因数据库引擎从-leveldb-迁移到-sqlite">根本原因：数据库引擎从 LevelDB 迁移到 SQLite</h2>

<p>Syncthing 2.0 最核心的变化是将底层的索引数据库引擎从 Google 的 [[LevelDB]] 切换到了 SQLite。Syncthing 用这个数据库存储所有文件的元数据、校验值和同步状态，这个库一旦发生格式变更，就意味着首次启动时必须完成一次完整的数据格式迁移。</p>

<p>官方文档对此的表述是：”数据库后端已从 LevelDB 切换到 SQLite。首次启动时会进行一次迁移，对于较大的数据集这个过程可能需要较长时间。”对于文件数量众多的用户，这个迁移甚至可以持续数小时乃至整夜。而在迁移完成之前，Syncthing 对所有文件夹展示的状态正是 “Preparing to Sync”——它在准备，只是用户看不到进度。</p>

<p>问题在于，通过 <code class="language-plaintext highlighter-rouge">brew services start syncthing</code> 作为系统服务运行时，Syncthing 的控制台输出被完全隐藏。用户打开 Web UI 只能看到一个没有进度提示的状态标签，却无从判断迁移是否还在进行、还是真的卡死了。这种信息缺失，是让人以为出了大问题的直接原因。</p>

<h2 id="确认迁移状态">确认迁移状态</h2>

<p>在采取任何激进操作之前，先确认 Syncthing 到底是真的卡住了，还是还在默默工作。</p>

<p>停止 Homebrew 服务，改为手动从终端启动 Syncthing，这样就能实时看到日志输出：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew services stop syncthing
syncthing
</code></pre></div></div>

<p>观察终端输出。以下这类日志都属于正常现象，说明 Syncthing 仍在工作中，不需要干预：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INF GC was interrupted due to exceeding time limit (processed=3 runtime=5m34s folder=default fdb=folder.0001-xxx.db table=blocks)
INF Completed initial scan (folder.label="Default Folder" folder.id=default folder.type=sendreceive)
</code></pre></div></div>

<p>第一行是 SQLite 数据库在做垃圾回收（GC），运行超过时间限制后被中断，这是 Syncthing 2.0 新引入的行为，中断不代表出错，GC 会在后续继续执行。第二行是初始扫描完成的确认，出现这行后文件夹就会脱离 “Preparing to Sync” 状态。</p>

<p>反之，如果日志几分钟内没有任何新内容，或者出现了 “database disk image is malformed” 这样的错误，才需要进行下一步处理。</p>

<h2 id="解决方案">解决方案</h2>

<h3 id="方案一耐心等待迁移完成">方案一：耐心等待迁移完成</h3>

<p>如果手动运行后看到迁移活动，最好的做法就是什么都不做，等它跑完。文件越多，等待时间越长。根据社区反馈，拥有十几万文件的用户，迁移时间可能超过两个小时。迁移完成后，Syncthing 会自动恢复正常同步，之后再用 <code class="language-plaintext highlighter-rouge">brew services start syncthing</code> 挂回后台服务即可。</p>

<h3 id="方案二重置索引数据库">方案二：重置索引数据库</h3>

<p>如果迁移确实卡死，或者日志中出现了数据库损坏相关的错误，就需要删除旧的索引数据库，让 Syncthing 从零开始重新扫描。</p>

<p>首先，找到 Syncthing 的数据目录：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># macOS 上默认路径</span>
<span class="nb">ls</span> ~/Library/Application<span class="se">\ </span>Support/Syncthing/
</code></pre></div></div>

<p>Syncthing 2.0 的 SQLite 索引数据库位于 <code class="language-plaintext highlighter-rouge">index-v2/</code> 目录下，每个同步文件夹对应一个独立的 <code class="language-plaintext highlighter-rouge">.db</code> 文件（如 <code class="language-plaintext highlighter-rouge">folder.0001-abcd1234.db</code>），以及配套的 <code class="language-plaintext highlighter-rouge">.db-shm</code> 和 <code class="language-plaintext highlighter-rouge">.db-wal</code> 文件。如果迁移卡死或数据库损坏，可以删除整个 <code class="language-plaintext highlighter-rouge">index-v2/</code> 目录让 Syncthing 重建：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew services stop syncthing
<span class="nb">rm</span> <span class="nt">-rf</span> ~/Library/Application<span class="se">\ </span>Support/Syncthing/index-v2/
</code></pre></div></div>

<p>如果你仍有旧版 LevelDB 格式的 <code class="language-plaintext highlighter-rouge">index-v0.14.0.db</code> 残留（升级异常时可能存在），也可以一并清理：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">rm</span> <span class="nt">-rf</span> ~/Library/Application<span class="se">\ </span>Support/Syncthing/index-v0.14.0.db
</code></pre></div></div>

<p>删除后重新启动 Syncthing，它会从头对所有文件夹执行完整扫描。文件本身不会丢失，只是 Syncthing 需要重新建立对所有文件的认知，这个过程同样需要一些时间，但通常比数据库迁移要快得多。</p>

<h3 id="方案三使用-reset-deltas-参数">方案三：使用 –reset-deltas 参数</h3>

<p>如果问题只是同步状态不一致，而数据库本身没有损坏，可以尝试使用 <code class="language-plaintext highlighter-rouge">--reset-deltas</code> 参数启动 Syncthing，让它重置增量同步状态：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew services stop syncthing
syncthing <span class="nt">--reset-deltas</span>
</code></pre></div></div>

<p>这个方法比删除整个数据库要温和，只清除增量同步的记录，不会触发全量重扫，适合作为第一道修复手段。</p>

<h3 id="方案四通过-rest-api-重置特定文件夹">方案四：通过 REST API 重置特定文件夹</h3>

<p>如果只有某一两个文件夹卡住，其他文件夹正常，可以通过 Syncthing 的 REST API 只重置问题文件夹的索引，避免影响已经工作正常的文件夹。</p>

<p>先在 Web UI 的设置里找到你的 API Key，然后执行：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 重置特定文件夹（将 your-folder-id 替换为实际的文件夹 ID）</span>
curl <span class="nt">-X</span> POST <span class="nt">-H</span> <span class="s2">"X-API-Key: your-api-key"</span> <span class="se">\</span>
  <span class="s2">"http://127.0.0.1:8384/rest/system/reset?folder=your-folder-id"</span>
</code></pre></div></div>

<p>如果不带 <code class="language-plaintext highlighter-rouge">folder</code> 参数，则会重置所有文件夹的数据库，效果等同于方案二。</p>

<h3 id="方案五彻底重装">方案五：彻底重装</h3>

<p>如果以上方案都不奏效，可以做一次干净的重装。这个方法对 macOS + Homebrew 环境效果最好：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew services stop syncthing
brew uninstall syncthing
brew <span class="nb">install </span>syncthing
</code></pre></div></div>

<p>重装完成后，先从终端手动启动一次，观察迁移日志，确认迁移正常完成后再切换回服务模式：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>syncthing
<span class="c"># 等待迁移完成，Ctrl+C 退出</span>
brew services start syncthing
</code></pre></div></div>

<h2 id="其他需要注意的变更">其他需要注意的变更</h2>

<p>除了数据库迁移，Syncthing 2.0 还有一些其他变化值得留意。日志格式改为结构化日志，旧的 <code class="language-plaintext highlighter-rouge">--verbose</code> 和 <code class="language-plaintext highlighter-rouge">--logflags</code> 命令行参数已被移除，改用 <code class="language-plaintext highlighter-rouge">--log-level</code> 参数控制日志级别。如果你的 Syncthing Web UI 不是绑定在默认的 <code class="language-plaintext highlighter-rouge">127.0.0.1:8384</code>，需要注意升级机制存在一个已知 bug，它硬编码了默认地址，非默认配置下可能导致自动升级失败。</p>

<p>另外，Syncthing 2.0 不再为部分平台提供预编译二进制文件，包括 DragonFlyBSD、Illumos、Linux on PowerPC64、NetBSD 和部分 OpenBSD 架构，这些平台的用户需要自行从源码编译。</p>

<h2 id="最后">最后</h2>

<p>这次 Syncthing 升级卡住的经历，再次提醒我在做系统级工具的大版本升级之前，最好先读一读 release notes。Syncthing 2.0 将 LevelDB 换成 SQLite 是一次从根基上的重构，好处是数据库更易于维护和调试，坏处是首次启动的迁移成本对数据量大的用户来说相当可观。</p>

<p>如果你遇到了同样的问题，按照本文的顺序依次尝试，大概率能在方案一或方案二这里就解决。关键是不要慌，不要在迁移过程中强行重启或删除数据，那样才是真的会造成问题。耐心等一等，或者删掉旧索引让它重建，[[Syncthing]] 本身的可靠性依然值得信任。</p>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="经验总结" /><category term="syncthing" /><category term="macos" /><category term="homebrew" /><category term="sync" /><category term="troubleshooting" /><summary type="html"><![CDATA[使用 Homebrew 升级 macOS 上的 Syncthing 之后，已同步好的文件夹一直卡在 preparing to sync 状态。本文分析根本原因并提供几种可行的解决方案。]]></summary></entry><entry><title type="html">Git 分支管理与发布上线流程实战指南</title><link href="https://blog.einverne.info/post/2026/07/git-branch-management-and-release-workflow.html" rel="alternate" type="text/html" title="Git 分支管理与发布上线流程实战指南" /><published>2026-07-03T00:00:00-05:00</published><updated>2026-07-03T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/07/git-branch-management-and-release-workflow</id><content type="html" xml:base="https://blog.einverne.info/post/2026/07/git-branch-management-and-release-workflow.html"><![CDATA[<p>最近有朋友来问我 Git 分支和发布流程相关的问题，聊着聊着我发现，分支管理这块我自己算是比较熟悉，平时用起来也有一套固定的习惯，但当话题转到 release 发布流程的时候，我反倒得停下来想一想。</p>

<p>因为发布这件事，在不同的团队里真的有截然不同的管理方式。有的团队一天上线十几次，有的团队一个月才发一个大版本；有的靠一条流水线全自动，有的还保留着人工审批的关卡；有的发布的是后端服务，回滚一次只要几分钟，有的发布的是手机 App，版本一旦进了用户手机，想收回来就没那么容易了。</p>

<p>同样是把代码送到用户面前，路径可以差得非常远。这次朋友的提问正好戳中了这个我一直觉得值得梳理却没动笔的话题，于是就有了这篇文章。</p>

<p><img src="https://pic.einverne.info/images/2026-07-03-10-00-00-git-branch-flow.png" alt="分支管理与发布上线流程示意" /></p>

<h2 id="为什么分支管理这么重要">为什么分支管理这么重要</h2>

<p>很多人刚开始用 Git 的时候，会把分支理解成一个简单的代码备份工具。我要做一个新功能，就从 main 拉一条分支；我要试验一个想法，也拉一条分支。这个理解没有错，但只看到了分支的第一层作用。</p>

<p>真正到了团队协作和生产发布里，分支管理解决的不是代码备份问题，而是代码流向问题。也就是说，哪一批代码可以进入测试环境，哪一批代码可以进入生产环境，哪一个 commit 对应当前线上版本，出了问题应该从哪里修，修完之后又应该合回哪里。</p>

<p>如果没有清晰的分支规则，团队很容易进入一种混乱状态：功能还没测完就被带到生产，线上 hotfix 修完忘了合回开发分支，测试环境和生产环境跑的代码说不清楚，发布时大家只能在群里反复确认“现在到底该发哪个 commit”。这种混乱短期看只是沟通成本，长期看就是事故来源。</p>

<p>所以我更愿意把分支管理看成发布系统的一部分，而不是单纯的 Git 使用技巧。分支只是表面，背后真正要设计的是从需求开发、代码合并、测试验证、版本标记、部署上线到回滚修复的完整链路。</p>

<h2 id="主流分支模型的取舍">主流分支模型的取舍</h2>

<p>常见的分支模型大致可以分成 Git Flow、GitHub Flow、GitLab Flow 和主干开发。它们没有绝对好坏，只有适不适合你的产品形态、团队规模和发布频率。</p>

<h3 id="git-flow">Git Flow</h3>

<p>[[Git Flow]] 是 Vincent Driessen 在 2010 年提出的模型，也是很多人接触的第一套正规分支规范。它定义了两条长期分支和三类临时分支。两条长期分支是 main 或者 master，以及 develop。main 永远对应生产环境上正在跑的代码，develop 是所有开发工作汇集的集成分支。三类临时分支分别是 feature、release 和 hotfix。</p>

<p>它的工作方式是这样的：每开发一个新功能，从 develop 拉出一条 feature 分支，做完之后合并回 develop。当 develop 上积累了足够一次发布的功能，就从 develop 拉出一条 release 分支，在这条分支上只做测试和 bug 修复，不再加新功能。release 稳定之后，同时合并进 main 和 develop，在 main 上打一个版本 tag，发布上线。如果线上出了紧急 bug，从 main 拉一条 hotfix 分支，修完后同样合并回 main 和 develop。</p>

<p><img src="https://pic.einverne.info/images/c_bR-STyVE.png" alt="c_bR-STyVE" /></p>

<p>Git Flow 的优点是职责极其清晰，每条分支都有明确的语义，特别适合那种有明确版本概念、需要同时维护多个版本、发布周期比较长的软件，比如安装包类桌面应用、企业客户私有化部署产品、需要给客户提供长期支持版本的产品。</p>

<p>但它的缺点也很突出：太重了。两条长期分支加上频繁的双向合并，会让整个流程变得繁琐。对于现在这种追求持续交付、一天上线好几次的 Web 应用来说，Git Flow 往往是过度设计，反而拖慢节奏。</p>

<h3 id="github-flow">GitHub Flow</h3>

<p>如果说 Git Flow 是重型装甲，[[GitHub Flow]] 就是轻装上阵。它只有一条长期分支 main，规则简单到一句话就能说完：main 永远是可部署的，任何改动都从 main 拉一条描述清晰的短分支，做完后开 PR，CI 通过、代码 review 通过，就合并回 main。</p>

<p>GitHub Flow 的核心假设是 main 分支始终保持健康，任何时刻都能拿去上线。它天然契合持续部署，你合并即上线，不存在攒一批功能再发布的概念。对于小团队、独立开发者、Web 服务、内部工具来说，这套流程非常高效。</p>

<p><img src="https://pic.einverne.info/images/nFoCeMqaZ0.png" alt="nFoCeMqaZ0" /></p>

<p>但它也有前提：自动化测试要足够可靠，review 要认真，部署流水线要成熟。否则“main 可部署”就只是一句口号。同时它缺少显式的环境概念，没有区分测试环境和生产环境的分支。如果你的发布必须经过多个环境层层验证，纯 GitHub Flow 就会显得有点薄。</p>

<h3 id="gitlab-flow">GitLab Flow</h3>

<p>[[GitLab Flow]] 可以看成是 GitHub Flow 的增强版，它在保持简单的基础上补上了环境和版本的概念。</p>

<p>一种常见做法是引入环境分支，比如除了 main，再加上 pre-production 和 production 分支。代码永远从上游流向下游：先合并到 main，main 部署到测试环境验证通过后，再把 main 合并到 pre-production，最后合并到 production 上线。这样每个环境对应一条分支，你随时能看到每个环境上跑的是哪一批代码。</p>

<p><img src="https://pic.einverne.info/images/uUj71QtQrd.png" alt="uUj71QtQrd" /></p>

<p>另一种做法是用 release 分支来支持版本发布。当你需要维护 2.3、2.4 这样的多个稳定版本时，为每个版本维护一条 release 分支，修复只从 main 往 release 分支 cherry-pick，保证 bug 修复的流向永远是从上游到下游，避免修了新版本忘了修老版本。</p>

<p>GitLab Flow 的价值在于它在简单和严谨之间找到了一个平衡点，既没有 Git Flow 那么繁琐，又比 GitHub Flow 多了对多环境、多版本的支持。</p>

<h3 id="主干开发">主干开发</h3>

<p>[[Trunk-Based Development]] 主干开发是近几年在大厂和高频交付团队里越来越流行的模型，它的理念有点反直觉：尽量不要有长期存在的分支。所有人都直接往主干，也就是 main 上频繁提交。即使要开分支，也应该是那种活不过一两天的极短生命周期分支，做完立刻合并。</p>

<p>这套做法要解决的核心问题是合并地狱。当分支存在的时间越长，它和主干的差异就越大，最后合并的时候冲突就越可怕。主干开发通过强制每个人频繁地把小改动合进主干，让分支永远不会偏离太远，冲突自然就小了。</p>

<p>那没做完的功能怎么办？答案是特性开关。代码合进去了，但入口用 feature flag 关着，直到功能完整、测试通过再打开。Google、Meta 这种超大规模团队用的都是主干开发的思路。</p>

<p>它的门槛是四种模型里最高的。你需要极其完善的自动化测试、成熟的特性开关基础设施、稳定的监控告警，以及团队对小步提交纪律的高度自觉。但一旦这些条件具备，主干开发能带来最快的集成速度和最少的合并痛苦，是持续交付的理想形态。</p>

<h2 id="环境与部署的对应关系">环境与部署的对应关系</h2>

<p>讲完分支模型，必须把它和环境部署对应起来，否则分支就是悬空的。一个典型的部署链路会有这么几个环境：开发环境、测试环境、预发布环境、生产环境。分支模型的作用，就是规定代码怎么从一个环境流到下一个环境，也规定每个环境应该接受什么成熟度的代码。</p>

<p>我一般会把环境分成四层来看。</p>

<p>开发环境（dev）是给开发者快速验证的地方，它对应的通常不是某一条固定分支，而是 feature 分支、个人分支，或者 main 上最新的开发构建。这个环境的目标是快，不是稳。开发环境可以频繁部署，可以被打断，可以连 mock 服务，也可以连一套临时数据库。管理上要注意两点：第一，不要让开发环境承担验收职责；第二，开发环境的数据和配置必须和生产隔离，不能因为图方便就直接连生产依赖。</p>

<p>测试环境（test）是给 QA、产品和团队做集成验证的地方，它通常对应 main、develop，或者专门的 integration 分支。小团队使用 GitHub Flow 时，我更建议 main 合并后自动部署到测试环境；如果团队还保留 develop 分支，那 develop 就应该是测试环境的事实来源。测试环境要比开发环境稳定，不能随便把半成品功能推进去影响别人验证。如果有多个功能并行开发，最好通过 feature flag 控制入口，而不是让测试环境在多个大分支之间来回切。</p>

<p>预发布环境，也就是 staging 或 pre-production，应该尽量模拟生产。它对应的代码通常是 release 分支、待发布 tag，或者某个已经通过测试环境验证的 commit。预发布环境的重点不是发现普通功能 bug，而是验证发布本身：配置是否正确，数据库迁移是否可执行，缓存、队列、定时任务、第三方回调、权限和网络策略是否和生产一致。我的建议是，预发布环境不要跟着每次 main 合并自动更新，而应该由 release candidate 触发部署，这样它代表的是“下一次准备上线的版本”。</p>

<p>生产环境只应该部署已经确认可发布的版本。它对应的可以是 production 分支，也可以是不可变的 Git tag、GitHub Release、镜像 digest 或者某个明确的 commit。我更偏向用 tag 或镜像 digest 标记生产，而不是长期维护一条 production 分支。因为生产环境最重要的是可追溯：你必须能回答现在生产跑的是哪个 commit、哪个构建产物、哪一组配置，以及它是通过哪一次流水线发布出去的。</p>

<p>如果把它们放成一条代码流，大致是这样：feature 分支先进入开发环境，合并到 main 或 develop 后进入测试环境，从 main 切 release 分支或创建 release candidate 后进入预发布环境，最后通过 tag、release 或审批流水线进入生产环境。这个过程里，代码只能向前流动，不能因为生产出了问题就直接在生产分支上手改，也不能把预发布环境里的临时补丁忘在角落里。</p>

<p>环境和分支的对应有两种常见做法。一种是环境即分支，像 GitLab Flow 那样，每个环境对应一条分支，例如 develop 对应测试环境，pre-production 对应预发布环境，production 对应生产环境。合并到哪条分支就部署到哪个环境。这种方式直观，适合流程需要强控制、团队成员对环境概念还不熟的阶段。</p>

<p>另一种是环境即版本，也就是环境不由分支定义，而由 tag、commit、release 或镜像 digest 定义。main 始终是唯一主线，测试环境自动跟随 main，预发布环境部署某个 release candidate，生产环境部署某个不可变 tag。这更符合主干开发和现代 [[GitOps]] 的理念，也更容易做到“同一份构建产物经过测试、预发布、生产逐级推进”。</p>

<p>我更倾向于第二种。因为环境分支看起来直观，但时间一长很容易变成环境分叉：staging 有一些改动，production 有另一些改动，最后谁也说不清哪个才是事实来源。用 commit、tag、镜像 digest 来描述环境状态，通常更干净，也更容易审计。</p>

<p>这里的关键在于自动化。你不应该让人去手动 build、手动上传文件、手动重启服务，这些都应该由 [[CI/CD]] 流水线在代码合并、创建 release candidate 或打 tag 的时候自动完成。手动操作是错误的温床，一次手滑就可能把测试环境配置带到生产。</p>

<h2 id="客户端版本发布的特殊性">客户端版本发布的特殊性</h2>

<p>如果你的产品有 iOS、Android、桌面客户端、浏览器插件，或者 Electron 这类需要安装到用户设备上的客户端，那么发布流程就不能只按后端服务的思路来设计。</p>

<p>后端发布失败，大多数时候可以马上回滚。客户端发布失败，问题就复杂得多。用户已经下载的 App 不会自动消失，应用商店审核需要时间，不同用户升级节奏不一致，还有一部分用户可能永远停留在旧版本。所以客户端版本管理的核心，不只是“把代码发出去”，而是“在多个客户端版本长期共存的情况下保持系统可用”。</p>

<p>客户端发版通常要同时管理两个版本号。一个是给用户看的 version name，比如 2.4.1；另一个是给商店和系统识别的 build number 或 version code，它必须单调递增。Git tag 通常对应 version name，而每一次提交给 TestFlight、Google Play、企业分发平台或者自动更新服务的构建，都应该有唯一的 build number，并能追溯到具体 commit。</p>

<p>客户端发布还要有更长的冻结窗口。进入 release candidate 阶段后，最好从 main 或 develop 拉出 release 分支，只允许修复阻断发布的问题，不再混入新功能。测试通过后，用同一份产物进入灰度、审核和正式发布。这里我特别强调“同一份产物”，不要测试包是一份，正式包又重新 build 一份。重新 build 就意味着重新引入不确定性。</p>

<p>客户端灰度也和后端不同。后端灰度通常是按流量切分，客户端灰度通常是按用户升级比例切分，比如先给百分之一用户，再扩大到百分之五、百分之二十，最后全量。灰度期间要盯住崩溃率、启动耗时、关键接口错误率、支付或登录等核心漏斗。如果指标异常，第一反应不是继续扩大比例，而是暂停发布、下架新版本或者提高服务端开关的保护力度。</p>

<p>最容易被忽略的是客户端和后端 API 的兼容关系。后端接口不能假设所有客户端都已经升级，客户端也不能假设服务端永远保持旧行为。比较稳妥的做法是后端先兼容新旧协议，客户端再逐步升级，等旧版本占比足够低之后，再清理旧字段和旧接口。对关键功能来说，服务端 feature flag、最低支持版本、强制升级弹窗、降级配置都应该提前设计好，而不是事故发生后再临时补。</p>

<p>所以客户端发布更适合“版本分支加灰度开关”的模式。分支负责冻结代码，tag 负责标记发布版本，build number 负责区分构建产物，远程配置和 feature flag 负责控制功能是否真正对用户开放。</p>

<h2 id="后端服务部署的完整链路">后端服务部署的完整链路</h2>

<p>后端服务发布看起来比客户端轻，因为它通常可以随时部署、随时回滚。但也正因为后端离数据、流量和依赖系统更近，它的部署流程不能只停留在“把代码 merge 到 main”。</p>

<p>一个比较健康的后端发布链路应该是这样的：代码合并后触发 CI，跑单元测试、集成测试、静态检查和安全扫描；通过后构建不可变制品，比如 Docker image，并把 commit hash、版本号、构建时间写入镜像标签或元数据；然后部署到测试环境跑 smoke test；确认通过后，再通过人工批准或自动策略进入生产部署。</p>

<p>后端部署方式常见有 rolling update、blue-green deployment 和 canary release。rolling update 是逐批替换实例，简单直接；blue-green 是准备两套完整环境，流量从旧环境切到新环境，回滚时再切回去；canary 是先让少量真实流量进入新版本，观察指标稳定后再扩大比例。小服务用 rolling update 足够，核心链路或高风险改动更适合 canary 或 blue-green。</p>

<p>后端部署里最容易出事故的不是代码本身，而是数据库变更。应用可以回滚，数据库 schema 一旦改坏就很麻烦。所以数据库迁移要遵循向前兼容的思路。比如先新增字段但不删除旧字段，代码同时兼容新旧字段，等新版本稳定并确认旧代码不再访问后，再做清理。这通常被称为 expand and contract 模式。</p>

<p>配置和密钥也要从部署流程里单独拎出来。代码制品应该是不可变的，同一个镜像可以部署到 staging，也可以部署到 production，差异来自环境变量、配置中心和密钥管理，而不是重新编译一份“生产专用包”。如果每个环境都重新 build，一方面难以追溯，另一方面也很难保证测试过的东西就是上线的东西。</p>

<p>上线之后，发布流程还没有结束。后端服务必须有健康检查、日志、指标、链路追踪和告警。发布完成后至少要观察错误率、延迟、CPU、内存、队列积压、数据库连接数和核心业务指标。一个成熟的流水线不只是负责 deploy，也应该负责在关键指标异常时自动停止扩容、暂停灰度，甚至触发回滚。</p>

<p>所以后端服务的分支策略可以很轻，但部署策略不能轻。Git 分支回答的是“哪段代码可以发布”，部署系统回答的是“这段代码如何安全地进入生产流量”。这两个问题必须放在一起看。</p>

<h2 id="版本号与发布的锚点">版本号与发布的锚点</h2>

<p>分支和部署之间还缺一个连接点，就是版本号。[[语义化版本]] 是目前最通用的约定，格式是主版本号、次版本号、修订号，比如 2.4.1。主版本号在有不兼容的 API 变动时加一，次版本号在新增向下兼容的功能时加一，修订号在做向下兼容的 bug 修复时加一。这套约定的好处是，别人光看版本号变化就能大致判断这次升级的风险有多大。</p>

<p>在 Git 里，版本通过 tag 来固化。每次正式发布，你都应该在对应的 commit 上打一个带版本号的 tag，比如 v2.4.1。tag 是不可变的锚点，它让你在任何时候都能精确地回到某个已发布版本的代码状态。当线上出问题需要回滚时，你不是慌乱地去找哪个 commit，而是直接回到上一个 tag。</p>

<p>我强烈建议把打 tag 这个动作和发布流程绑定。很多团队会配置成打 tag 自动触发生产部署，或者创建 GitHub Release 后触发发布流水线。这样 tag 既是版本标记，又是发布触发器，一举两得。</p>

<p>配合 tag，维护一份清晰的 changelog 也很重要。每个版本发布时，记录下这个版本包含哪些新功能、修了哪些 bug、有没有破坏性变更、是否需要数据库迁移、客户端最低兼容版本是多少。这份记录既是给用户看的，也是给未来的自己看的。当半年后你想知道某个行为是从哪个版本开始变的，changelog 就是最快的答案。</p>

<h2 id="热修复的正确姿势">热修复的正确姿势</h2>

<p>线上救火是每个团队都躲不掉的场景，而热修复流程恰恰最能检验你的分支策略是否健全。</p>

<p>糟糕的做法是，发现线上 bug 后，直接在开发分支上改，改完把开发分支上一堆没测完的东西一起推上线，结果旧 bug 没解决又带出新 bug。正确的做法是，热修复必须基于当前生产环境正在运行的那个版本，也就是从 main 分支、生产 tag 或 release 分支拉出一条独立的 hotfix 分支。</p>

<p>在这条 hotfix 分支上，你只做和这个 bug 相关的最小改动，不夹带任何其他东西。修复完成、测试通过后，合并回 main 并立即部署上线，同时打一个修订号加一的新 tag。</p>

<p>这里有一个容易被忽略的关键步骤：热修复的改动必须同步回开发主线。在 Git Flow 里是合回 develop，在主干开发里就是确保 main 已经包含这次修复。如果客户端还有 release 分支，也要把修复 cherry-pick 回对应的发布分支。否则下次正常发布的时候，这个 bug 会因为开发分支上没有这个修复而重新出现，这种回归 bug 特别隐蔽也特别让人抓狂。</p>

<p>客户端热修复还要多考虑一层：如果新版已经进入应用商店审核或者已经部分灰度，修复版本可能需要重新提审，这中间有时间差。所以客户端关键功能一定要能通过远程配置关闭，后端也要保留对旧客户端的兜底兼容。不要把所有希望都寄托在“赶紧发一个新包”上。</p>

<p>热修复流程的本质，是在紧急情况下依然保持隔离的纪律。越是着急，越容易图省事直接乱改，而这恰恰是把小事故变成大事故的根源。把热修复流程规范化，甚至写成脚本或者文档贴在显眼处，能在关键时刻救你一命。</p>

<h2 id="我的实践建议">我的实践建议</h2>

<p>讲了这么多模型和流程，落到实处，我想给几条具体的、不同场景下的建议。</p>

<p>如果你是个小团队或者独立开发者，做的是 Web 应用或者服务，别犹豫，直接用 GitHub Flow。一条 main 分支，功能分支开 PR 合并，配好 CI 自动测试和部署。不要一上来就套 Git Flow 那套复杂规范，你会被繁琐的流程拖垮，而且大概率用不上它的多版本维护能力。等团队和复杂度真的涨上来了，再考虑往 GitLab Flow 演进，加上环境分支或发布分支。</p>

<p>如果你做的是有明确版本概念、需要给客户提供长期支持、或者要同时维护多个大版本的软件，那 Git Flow 或者 GitLab Flow 的 release 分支模式是合适的。它的繁琐在这种场景下是必要的代价，因为你需要清楚地知道每个客户、每个环境、每个版本分别停在哪里。</p>

<p>如果你做的是客户端产品，我会建议保留 release 分支。main 可以保持快速迭代，但每次准备发版时从 main 切出 release 分支，进入代码冻结、测试、灰度和正式发布。发布后 tag 固化版本，后续只允许 hotfix 进入这条 release 分支。这样既不影响主线继续开发，也能保证客户端版本稳定。</p>

<p>如果你做的是后端服务，我会建议把精力更多放在流水线和部署策略上。分支可以简单，但要确保构建产物不可变、部署可追踪、数据库迁移可回滚或至少可兼容、灰度可观察、异常可快速止血。后端的安全感不是来自很多分支，而是来自自动化测试、监控告警和可重复的部署流程。</p>

<p>无论用哪种模型，有几条纪律是通用的。保持 main 分支永远可部署，这是底线。功能分支要小而短命，一个分支只做一件事，尽快合并，别让它在角落里存活好几周。所有合并都要走 PR、CI 和 review，不要绕过流程直接 push。每次发布都要有 tag、changelog 和可追溯的构建产物。热修复一定要从生产版本出发，并且修完之后合回主线。</p>

<h2 id="最后">最后</h2>

<p>分支管理没有银弹。Git Flow、GitHub Flow、GitLab Flow、主干开发，本质上都是在不同约束下对同一个问题的回答：如何让多人协作的代码，以尽量低风险、可追踪、可回滚的方式进入用户手里。</p>

<p>如果只看代码仓库，很多流程都显得差不多。但一旦把客户端版本发布、后端服务部署、数据库迁移、灰度策略、应用商店审核、线上监控这些因素放进来，你就会发现发布系统其实是一个整体。分支只是入口，真正决定稳定性的，是从 commit 到用户之间的每一道闸门。</p>

<p>我现在看一个团队的工程成熟度，已经不太会只问“你们用什么分支模型”。我更关心的是：main 是否随时可部署，发布是否有明确版本锚点，客户端和后端是否互相兼容，数据库变更是否可控，灰度指标是否有人看，出问题时是否能在几分钟内知道该回滚哪个版本。</p>

<p>能把这些问题回答清楚，分支模型反而只是一个自然结果。流程不是为了显得专业，而是为了在真正出问题的时候，让团队不用靠记忆和运气救火。</p>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="经验总结" /><category term="git" /><category term="git-flow" /><category term="github-flow" /><category term="trunk-based-development" /><category term="ci-cd" /><category term="release-management" /><category term="devops" /><category term="version-control" /><category term="semantic-versioning" /><category term="branching-strategy" /><category term="mobile-release" /><category term="backend-deployment" /><summary type="html"><![CDATA[系统梳理 Git Flow、GitHub Flow、GitLab Flow 与主干开发四种分支模型，并从客户端版本发布、后端服务部署、语义化版本、热修复与回滚流程出发，给出一套可落地的分支管理实践建议。]]></summary></entry><entry><title type="html">Orca ADE 体验：为 AI 编码 Agent 而生的开发环境，用 worktree 让一群 Agent 并行干活</title><link href="https://blog.einverne.info/post/2026/07/orca-agent-development-environment.html" rel="alternate" type="text/html" title="Orca ADE 体验：为 AI 编码 Agent 而生的开发环境，用 worktree 让一群 Agent 并行干活" /><published>2026-07-03T00:00:00-05:00</published><updated>2026-07-03T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/07/orca-agent-development-environment</id><content type="html" xml:base="https://blog.einverne.info/post/2026/07/orca-agent-development-environment.html"><![CDATA[<p>过去一年里软件的开发方式发生了一个我自己都没料到的转变。以前写代码是工程师对着编辑器敲，现在更多时候是同时开着好几个 [[Claude Code]]、[[Codex]] 之类的编码 Agent，让它们各自去完成一个任务，工程师只负责在中间调度、审阅、拍板。这种”我带一队 Agent 干活”的模式效率确实高，但很快就撞上了一堆现实的麻烦：几个 Agent 都在同一个仓库里改文件，分支互相污染；终端窗口开了一大排，切来切去分不清哪个是哪个；改完还得一个个去看 diff、跑测试、开 PR。工具还是那套为单人设计的 IDE，可我干活的方式早就不是单人了。</p>

<p>直到最近我发现了 [[Orca]] 这个东西，才第一次感觉到有款工具是真的照着”人加一群 Agent”这个新范式来设计的。它来自 onorca.dev，官方给自己的定位是 Agent Development Environment，简称 ADE。这篇文章就聊聊它到底是什么、解决了什么问题、有哪些让我觉得对路的特性，以及怎么装来用。</p>

<p><img src="https://pic.einverne.info/images/2026-07-03-orca-ade-cover.png" alt="Orca ADE：为 AI 编码 Agent 而生的开发环境" /></p>

<h2 id="什么是-ade它和-ide-有什么不一样">什么是 ADE，它和 IDE 有什么不一样</h2>

<p>Orca 官方有一句话把它的立场讲得很清楚：IDE 是为你设计的，ADE 是为你和你的 Agent 一起设计的。这句话初听有点像营销辞令，但你真正被前面那些麻烦折磨过之后，就会明白它戳中的是一个实实在在的空档。</p>

<p>传统 [[IDE]] 的每一个交互假设都建立在”一个人在写代码”上：一个工作区、一个当前分支、一个焦点文件、一个终端。可当你手里有五个 Agent 各自在忙的时候，这套假设全线崩溃。你需要的是五个互不干扰的隔离环境、五个可以同时观察的工作面、以及一套能让你快速在它们之间切换和汇总的界面。Orca 干的事，就是把 IDE 里那些为单人优化的部分，重新按照”多 Agent 并行”的思路组织了一遍，同时把终端、文件编辑器、浏览器、Git 工具这些原本散落在各处的东西，统统收进了一个应用里。</p>

<p>它背后是 stablyai 团队，拿了 [[Y Combinator]] 的投资，整个产品以 MIT 协议开源，代码托管在 GitHub 上。对我来说，一个还在高速迭代、又完全开源免费的开发工具，天然就少了很多顾虑，这也是我愿意认真投入时间去试它的一个前提。</p>

<h2 id="它最核心的一招是用-worktree-做隔离">它最核心的一招是用 worktree 做隔离</h2>

<p>如果只让我记住 Orca 的一个设计，那一定是它把 [[git worktree]] 当成了并行工作的基本单位。</p>

<p>熟悉 Git 的人都知道 worktree 这个特性：它允许你从同一个仓库里检出多个工作目录，每个目录停在不同的分支上，彼此完全独立。Orca 把这个能力做成了产品的地基，每一个任务、每一个 Agent，都跑在自己专属的 worktree 里。这意味着 Agent A 在重构模块、Agent B 在写测试、Agent C 在改文档，它们同时动手却谁也不会踩到谁，因为它们物理上就工作在不同的目录、不同的分支上。用官方的说法，就是不用再 stash、不用再手忙脚乱地切分支。</p>

<p>这一招看似简单，实际用起来的爽感却很难被替代。我以前手动维护多个 worktree 时，最烦的就是记不清哪个目录对应哪个任务，终端 cd 来 cd 去很容易搞错。Orca 把这层管理彻底接管了，每个 worktree 有清晰的名字、独立的终端、独立的编辑器状态，我只需要关心任务本身，底层那套 Git 的杂活它替我料理干净了。对于同时驱动多个 Agent 的工作流来说，这种隔离不是锦上添花，而是让并行真正可用的前提。</p>

<h2 id="那些让并行真正好用的特性">那些让并行真正好用的特性</h2>

<p>隔离只是地基，Orca 在上面还堆了不少让多 Agent 协作真正顺手的东西，我挑几个印象最深的讲。</p>

<p>它几乎不挑 Agent。官方宣称支持 25 种以上预配置的 CLI Agent，[[Claude Code]]、[[Codex]]、Cursor、[[GitHub Copilot]]、Grok、Gemini、OpenCode、Goose、Cline 这些主流的都在列，而且它的原则是任何 CLI 形态的 Agent 都能接进来。这一点对我很重要，因为我并不想被绑死在某一家 Agent 上，不同任务我会想用不同的模型和工具，Orca 这种不站队的态度让它更像一个中立的指挥台，而不是某个 Agent 的专属外壳。</p>

<p>它给每个 worktree 配了一个真正的浏览器。这不是内嵌一个网页控件那么简单，而是每个工作区都跑着一个独立的 Chromium 窗口，官方把它叫做 Design Mode。你在做前端时，可以直接在这个浏览器里点选某个界面元素，把对应的 HTML、CSS 甚至截图打包发回给 Agent，让它照着改。这等于在人、浏览器、Agent 之间搭了一条极短的反馈回路，我试过让 Agent 调整一个页面样式，指着浏览器里的元素说”就这块，边距太大”，比在聊天框里用文字描述半天精确得多。</p>

<p>它把代码审阅这件事做到了 Agent 时代该有的样子。Orca 内置了 [[VS Code]] 那套编辑器体验，更关键的是它的 diff 审阅：你可以在 Agent 改动的任意一行上写 Markdown 评论，把这些评论攒成一批，然后一键发回给 Agent 让它据此修改。这个循环解决了我一直以来的一个痛点，就是审阅 AI 的改动时脑子里冒出一堆意见，却没有一个顺手的地方把它们结构化地喂回去。除此之外，检查 CI、解决冲突、开 PR 全都能在应用内完成，甚至可以直接浏览 PR、Issue 和 [[Linear]] 的看板，创建 Issue、审批 PR，整个过程不需要在浏览器和编辑器之间反复横跳。</p>

<p>它还有一个我特别欣赏的设计，就是 Orca 本身是可以被脚本驱动的。它提供了一个叫 Orca CLI 的命令行接口，你可以在任何 shell 里用命令去操作一个正在运行的 Orca 编辑器，创建和检查 worktree、驱动 Agent 的终端、打开文件和 diff、甚至自动化那个内置浏览器。它的浏览器支持像 orca snapshot、orca click、orca fill 这样的脚本命令，操作的还是你正在交互的那个浏览器、那些标签页。这里最妙的一层是，既然 Orca 能被 CLI 驱动，那 Agent 自己也可以通过 CLI 来驱动 Orca，于是你就有了让 Agent 编排 Agent 的可能性。配合它对定时自动化、worktree 检查点、以及 skills 注册表加 [[MCP]] 的支持，这套东西的想象空间一下就被打开了。</p>

<p>对于那些跑在别处的任务，它支持 SSH worktree。你可以让 Agent 在远程机器上干活，比如需要长时间构建的服务器或者带 GPU 的机器，Orca 提供了自动重连、端口转发、密码短语缓存这些贴心的细节，让远程和本地的体验尽量接近。而当我人不在电脑前时，它还有一个移动端伴侣应用，值得单独提一句它的隐私设计：手机端是个纯粹的瘦客户端，代码、shell、Agent 全都还跑在你的桌面上，手机上什么都不运行，只是个观察窗口。配对走的是端到端加密，桌面生成一次性密钥对、显示二维码，手机扫一下之后两端之间的所有流量都用这对密钥封起来。这种把安全边界想清楚的做法，让我对把它接进日常工作流放心了不少。</p>

<p>最后是那些体现工程品味的细节。它的终端用 WebGL 渲染，据说灵感来自 [[Ghostty]]，支持无限分屏，而且重启后连回滚缓冲都能恢复。整个界面的组织方式是分屏面板，你可以把 Agent、终端、浏览器、diff、文件按照任务的形状自由地拼进一个个分屏里。也就是说，如果一个任务需要一个 Agent 加两个终端加一个浏览器，你就照着这个形状把界面铺开，而不是被工具的固定布局绑住。</p>

<h2 id="如何安装和上手">如何安装和上手</h2>

<p>安装很直接，去官网 onorca.dev 就能下载。桌面端覆盖了 macOS 的 Apple Silicon 和 Intel 两种芯片、Windows 以及 Linux 的 AppImage，移动端有 iOS 和 Android。如果你和我一样喜欢用包管理器，macOS 上可以直接用 Homebrew 装：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install</span> <span class="nt">--cask</span> stablyai/orca/orca
</code></pre></div></div>

<p>Arch Linux 用户则可以从 AUR 安装。它整个是开源的，源码就在 github.com/stablyai/orca ，想深入了解实现或者提 issue 都很方便。</p>

<p>我的上手建议是，别一上来就想着五个 Agent 齐飞，先从两个 worktree 开始感受隔离带来的清爽。挑两个互不相关的小任务，各开一个 worktree，各派一个 Agent，你会立刻体会到那种”它们在各自的世界里忙、我在上帝视角看着”的踏实感。等这套心智模型建立起来，再逐步增加并行度，同时去摸索它的 diff 标注和 PR 流程，把审阅这个环节也纳入进来。真正想榨干它价值的话，最后一定要去玩玩 Orca CLI，试着写个脚本去驱动一个 worktree，那一刻你会意识到 Orca 不只是个界面，而是一个可编程的 Agent 编排平台。</p>

<h2 id="它适合谁">它适合谁</h2>

<p>聊了这么多，我也得说清楚它的适用边界，免得你带着错误的期待去用。</p>

<p>如果你已经在用 AI 编码 Agent，而且经常不止开一个，那 Orca 几乎是为你现在的痛点量身准备的，它解决的正是并行、隔离、审阅、编排这一整条链路上的摩擦。如果你是那种喜欢把工具用到极致、乐于写脚本做自动化的人，那它的 CLI 和 MCP 支持会给你巨大的折腾空间。但反过来，如果你目前还是老老实实一个人一行行写代码，偶尔才让 AI 补全一下，那 Orca 对你可能就有点重了，一个功能完整的传统 IDE 反而更贴合你的节奏。说到底，Orca 的价值密度和你的并行度是正相关的，你手里的 Agent 越多，它替你省下的心力就越可观。</p>

<h2 id="一些缺点">一些缺点</h2>

<h3 id="中文字体渲染虚化">中文字体渲染虚化</h3>

<p>我目前升级到了最新的稳定版本 v1.4.128，但是在 Agent 中渲染中文的时候还是感觉有虚化问题，调整了字体，也让 AI 尝试解决了一下问题，还是没有解决方案。</p>

<p>下面的截图，左侧是 Orca，右侧是 [[Muxy]]，可以明显看到字体渲染差别。</p>

<p><img src="https://pic.einverne.info/images/dgyNyrUPoe.png" alt="dgyNyrUPoe" /></p>

<h3 id="基于-electron-内存和应用体积较大">基于 Electron 内存和应用体积较大</h3>
<p>Orca 是基于 Electron 构建，应用占用的内存相对较大，体积也较大，在使用流畅程度上也能体验到稍微的延迟。</p>

<h3 id="手机应用只能依赖桌面端">手机应用只能依赖桌面端</h3>
<p>Orca 的手机应用并非可以独立运行，它必须依赖桌面端在线。如果网络不稳定或者笔记本睡眠，手机端就会失去连接。</p>

<h3 id="远程功能依赖-orca-自带的-agent">远程功能依赖 Orca 自带的 agent</h3>
<p>远程 worktree 功能需要在远程机器上安装 Orca 自带的 agent ，如果是自己的 VPS ，那当然没问题，但是如果是安全审查严格的企业，可能会存在安全性问题。</p>

<h2 id="最后">最后</h2>

<p>回头看这大半年，最大的变化其实不是我用了哪个更强的模型，而是我组织工作的方式变了。当写代码从”我做”变成”我调度一群 Agent 做”，我需要的工具也就从一个更好的编辑器，变成了一个更好的指挥台。Orca 打动我的地方，正是它没有假装这个转变不存在，而是老老实实地围绕”人加一群 Agent”重新想了一遍开发环境该长什么样，然后用 worktree 隔离、可脚本化的 CLI、内建的浏览器和 PR 流程，把这个想法落成了一个真能用的产品。</p>

<p>对我个人而言，最大的收获是它帮我把一个模糊的直觉具体化了：并行运行 Agent 不是简单地多开几个窗口，而是需要一整套隔离、观察、审阅、编排的基础设施来支撑，缺了这套底座，并行度一上去就会乱成一团。Orca 让我看清了这套底座的形状。它还很年轻，功能几乎每天都在更新，未来会长成什么样谁也说不准，但它所代表的这个方向，也就是从 IDE 走向 ADE，我是真心相信会是接下来相当长一段时间里开发工具演进的主线。如果你也已经在带着 Agent 一起干活，我强烈建议你去装一个免费开源的 Orca，用两个 worktree 认真跑一跑，感受一下什么叫真正为 Agent 时代设计的开发环境。</p>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="产品体验" /><category term="orca" /><category term="ade" /><category term="ai-agent" /><category term="coding-agent" /><category term="git-worktree" /><category term="developer-tools" /><summary type="html"><![CDATA[介绍 onorca.dev 出品的 Orca：什么是 ADE，它如何用 git worktree 让多个 AI 编码 Agent 并行工作而互不干扰，有哪些核心特性，如何安装使用，并分享真实的使用思路与体验。]]></summary></entry><entry><title type="html">herdr 一个窗口调度多个 Coding Agent</title><link href="https://blog.einverne.info/post/2026/07/herdr.html" rel="alternate" type="text/html" title="herdr 一个窗口调度多个 Coding Agent" /><published>2026-07-01T00:00:00-05:00</published><updated>2026-07-01T00:00:00-05:00</updated><id>https://blog.einverne.info/post/2026/07/herdr</id><content type="html" xml:base="https://blog.einverne.info/post/2026/07/herdr.html"><![CDATA[<h2 id="什么是-herdr">什么是 Herdr</h2>

<p><a href="https://herdr.dev/">Herdr</a> 是一个运行在终端里的 AI 编码 agent 多路复用器（agent multiplexer）。官方用一句话概括它的定位：Herdr 之于编码 agent，就像 [[tmux]] 之于终端。它运行在你的 agent 运行的地方——本地机器、服务器，或任何可以 ssh 进去的环境，让你在一个终端里同时观察和操作多个正在工作的 agent。</p>

<p>随着 [[Claude Code]]、[[Codex]]、[[OpenCode]] 这类终端原生的编码 agent 流行起来，开发者常常会同时跑好几个 agent 处理不同的任务或仓库。问题随之而来：哪个 agent 在干活，哪个卡住了在等你确认，哪个已经做完了？Herdr 解决的就是这个“一群 agent 的可见性与编排”问题，把整个“herd”（畜群，这里指你养的一群 agent）收拢进一个终端。</p>

<p>Herdr 使用 [[Rust]] 编写，是开源项目，在 GitHub 上托管于 <code class="language-plaintext highlighter-rouge">ogulcancelik/herdr</code>。它强调 mouse-first（鼠标优先）和 agent-aware（感知 agent 状态），并且不依赖 Electron，是真正的终端原生工具。</p>

<table>
  <tbody>
    <tr>
      <td><a href="https://www.bilibili.com/video/BV1ZtT76aEcV">Bilibili</a></td>
      <td><a href="https://www.youtube.com/watch?v=g3wtTvm04W4">YouTube</a></td>
    </tr>
  </tbody>
</table>

<iframe src="//player.bilibili.com/player.html?isOutside=true&amp;aid=116850876751444&amp;bvid=BV1ZtT76aEcV&amp;cid=39596918168&amp;p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe>

<h2 id="核心功能">核心功能</h2>

<p>Herdr 的核心价值在于把多个 agent 的状态可视化并集中控制。</p>

<ul>
  <li>状态一览：在侧边栏中以 working（工作中）、blocked（被阻塞，等待确认）、done（已完成）、idle（空闲）等状态实时显示每个 agent 的进展，一眼就能看出谁需要你介入。</li>
  <li>点击即达：可以直接点击任意 pane、agent 或 workspace 跳转过去，处理被阻塞的 agent，再切回其他任务。</li>
  <li>会话持久化：类似 tmux，detach（断开）后再 reattach（重连），pane 与 agent 都不会死掉。官方的卖点是“合上笔记本，什么都不会消失”。</li>
  <li>远程与 SSH：可以在远程服务器上运行 Herdr，通过 SSH bridge 连接，本地终端就能管理跑在服务器上的一群 agent。</li>
  <li>原生 agent 恢复：支持 restart restore、pane history replay 以及 live handoff（在替换 server 进程时，pane 的 PTY 仍然存活，长任务继续响应）。</li>
  <li>通知机制：agent 状态翻转（比如从 working 变为 blocked）时可以触发通知，提醒你去处理。</li>
</ul>

<h2 id="核心概念">核心概念</h2>

<p>Herdr 用一套层级化的概念来组织工作空间：</p>

<ul>
  <li>Session：顶层命名空间，整个会话环境。</li>
  <li>Workspace：项目级别的工作区，侧边栏会把该项目下所有 agent 的状态汇总（roll up）显示。</li>
  <li>Tab：workspace 内的标签页，例如 agents、logs、server 等。</li>
  <li>Pane：最小的终端单元，每个 pane 承载一个 PTY，可以是一个 agent，也可以是普通的 shell 命令（如 <code class="language-plaintext highlighter-rouge">bun run dev</code>、<code class="language-plaintext highlighter-rouge">tail -f</code>、<code class="language-plaintext highlighter-rouge">python3 -m http.server</code>）。</li>
</ul>

<p>这套模型对用过 tmux 或 [[Zellij]] 的人来说很熟悉，但 Herdr 在其上叠加了对 agent 状态的语义理解。</p>

<p><img src="https://pic.einverne.info/images/5b4JYetkWF.png" alt="5b4JYetkWF" /></p>

<h2 id="安装与使用">安装与使用</h2>

<p>Herdr 在 Linux 和 macOS 上提供稳定版，Windows 目前是 preview beta（仅供预览）。</p>

<p>脚本安装（Linux/macOS）：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsSL</span> https://herdr.dev/install.sh | sh
</code></pre></div></div>

<p>此外还支持 Homebrew 与 [[Nix]] flake 安装。Windows 预览版通过 PowerShell：</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">irm</span><span class="w"> </span><span class="nx">https://herdr.dev/install.ps1</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">iex</span><span class="w">
</span></code></pre></div></div>

<p>默认键位前缀沿用 tmux 的习惯 <code class="language-plaintext highlighter-rouge">ctrl+b</code>，例如 <code class="language-plaintext highlighter-rouge">prefix+v</code> / <code class="language-plaintext highlighter-rouge">prefix+c</code> 用于切分 pane，<code class="language-plaintext highlighter-rouge">prefix+q</code> 等。对完全没接触过多路复用器的用户，Herdr 主打鼠标优先：可以点击 pane、拖拽边框、通过右键菜单切分和切换，不需要先背快捷键。</p>

<p>配置文件位于 <code class="language-plaintext highlighter-rouge">~/.config/herdr/config.toml</code>，可以自定义键位、主题、侧边栏行为、通知与滚动缓冲等。</p>

<h2 id="键盘快捷键">键盘快捷键</h2>

<p>Herdr 有三种输入模式，快捷键的含义取决于当前所在模式：</p>

<ul>
  <li>Terminal mode：按键直接发送到当前聚焦的 Pane（默认模式）</li>
  <li>Prefix mode：按下 <code class="language-plaintext highlighter-rouge">ctrl+b</code> 后触发，执行单次 Herdr 命令</li>
  <li>Navigate mode：持久化导航界面，用于在 Pane 间移动</li>
</ul>

<p>默认前缀键为 <code class="language-plaintext highlighter-rouge">ctrl+b</code>，可在 <code class="language-plaintext highlighter-rouge">~/.config/herdr/config.toml</code> 中自定义。</p>

<h3 id="pane-管理">Pane 管理</h3>

<table>
  <thead>
    <tr>
      <th>快捷键</th>
      <th>功能</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+v</code></td>
      <td>向右垂直分屏</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+minus</code></td>
      <td>向下水平分屏</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+h/j/k/l</code></td>
      <td>在 Pane 间移动（左/下/上/右）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+shift+h/j/k/l</code></td>
      <td>交换相邻 Pane</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+z</code></td>
      <td>放大/还原当前 Pane</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+x</code></td>
      <td>关闭当前 Pane</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+r</code></td>
      <td>进入调整大小模式</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+[</code></td>
      <td>进入复制模式</td>
    </tr>
  </tbody>
</table>

<h3 id="tab-管理">Tab 管理</h3>

<table>
  <thead>
    <tr>
      <th>快捷键</th>
      <th>功能</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+c</code></td>
      <td>新建 Tab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+n</code></td>
      <td>切换到下一个 Tab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+p</code></td>
      <td>切换到上一个 Tab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+1..9</code></td>
      <td>直接跳转到对应编号的 Tab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+T</code></td>
      <td>重命名当前 Tab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+X</code></td>
      <td>关闭当前 Tab</td>
    </tr>
  </tbody>
</table>

<h3 id="workspace-与会话">Workspace 与会话</h3>

<table>
  <thead>
    <tr>
      <th>快捷键</th>
      <th>功能</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+N</code></td>
      <td>新建 Workspace</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+W</code></td>
      <td>重命名 Workspace</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+D</code></td>
      <td>关闭 Workspace</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+w</code></td>
      <td>打开 Workspace 导航</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+g</code></td>
      <td>跳转选择器（快速跳到任意 Pane）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+b</code></td>
      <td>切换侧边栏显示/隐藏</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+q</code></td>
      <td>Detach 会话（后台继续运行）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefix+?</code></td>
      <td>查看所有快捷键帮助</td>
    </tr>
  </tbody>
</table>

<h3 id="无前缀直接绑定">无前缀直接绑定</h3>

<p>不需要按前缀键，直接触发：</p>

<table>
  <thead>
    <tr>
      <th>快捷键</th>
      <th>功能</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+alt+h/j/k/l</code></td>
      <td>聚焦左/下/上/右 Pane</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+alt+[</code></td>
      <td>切换到上一个 Tab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+alt+]</code></td>
      <td>切换到下一个 Tab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+alt+c</code></td>
      <td>新建 Tab</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+alt+d</code></td>
      <td>垂直分屏</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+alt+shift+d</code></td>
      <td>水平分屏</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+alt+z</code></td>
      <td>放大/还原当前 Pane</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+p</code></td>
      <td>打开命令面板</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ctrl+k</code></td>
      <td>搜索</td>
    </tr>
  </tbody>
</table>

<h3 id="复制模式copy-mode">复制模式（Copy Mode）</h3>

<p>进入复制模式（<code class="language-plaintext highlighter-rouge">prefix+[</code>）后的操作：</p>

<table>
  <thead>
    <tr>
      <th>按键</th>
      <th>功能</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">h/j/k/l</code>、<code class="language-plaintext highlighter-rouge">w/b/e</code>、<code class="language-plaintext highlighter-rouge">{</code>/<code class="language-plaintext highlighter-rouge">}</code></td>
      <td>移动光标</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v</code> 或 <code class="language-plaintext highlighter-rouge">Space</code></td>
      <td>开始选择</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">y</code> 或 <code class="language-plaintext highlighter-rouge">Enter</code></td>
      <td>复制选中内容</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">q</code> 或 <code class="language-plaintext highlighter-rouge">Esc</code></td>
      <td>退出复制模式</td>
    </tr>
  </tbody>
</table>

<h3 id="其他">其他</h3>

<table>
  <thead>
    <tr>
      <th>快捷键</th>
      <th>功能</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Esc</code></td>
      <td>中断当前操作</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">shift+tab</code></td>
      <td>循环切换权限模式（permission mode）</td>
    </tr>
  </tbody>
</table>

<h2 id="与-agent-的集成">与 Agent 的集成</h2>

<p>Herdr 一个有意思的设计是它本身对 agent 友好。它附带一个 skill 文件 <code class="language-plaintext highlighter-rouge">SKILL.md</code>，安装时会写入 agent 的指令目录（例如 [[Claude Code]] 的 <code class="language-plaintext highlighter-rouge">~/.claude/skills/herdr/SKILL.md</code>，Codex 的 <code class="language-plaintext highlighter-rouge">~/.codex/AGENTS.md</code>），这样 agent 在 pane 内部就能原生地理解并执行 Herdr 命令。</p>

<p>官方甚至建议让 agent 自己来完成 onboarding，把下面这段提示丢给正在运行的 agent 即可：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Help me understand and set up Herdr. Read https://herdr.dev/agent-guide.md first, then walk me through it step by step.
</code></pre></div></div>

<p>Herdr 还提供 CLI 与本地 socket API，允许脚本、工具和 agent 通过编程方式控制 Herdr，实现自动化编排。</p>

<h2 id="插件与生态">插件与生态</h2>

<p>Herdr 支持本地可执行的工作流插件（plugin），通过 manifest 定义 action 和 event hook 来扩展功能。社区可以通过 GitHub 分享插件并打 tag，待官方 marketplace 上线后被收录。主题方面内置了对 catppuccin、tokyo night 等流行配色的支持。</p>

<h2 id="对比分析">对比分析</h2>

<p>Herdr 官方提供了与同类工具的对比，核心差异在于“终端原生”与“感知 agent 状态”两点：</p>

<ul>
  <li>对比 [[tmux]] / [[Zellij]]：传统多路复用器只管理终端 PTY 与会话持久化，并不理解里面跑的是什么 agent，也不会汇总 working / blocked / done 状态。Herdr 在持久化之外叠加了 agent 语义。</li>
  <li>对比 [[cmux]]、[[Solo]]、[[Conductor]]、[[Emdash]] 等 agent 编排工具：这些工具不少是 GUI 或 Dashboard 形态，或基于 worktree 调度。Herdr 的差异是完全活在终端里，支持远程 SSH，且以 blocked 状态的可见性为核心。</li>
  <li>对比 [[Warp]]：Warp 是 UX 打磨精良的现代终端，但它是面向人的终端体验；Herdr 专注于多 agent 的并行可见与编排。</li>
</ul>

<p>简单说，如果你需要的是“在终端里同时盯住一群编码 agent，谁卡住了立刻去救”，Herdr 的定位最贴合；如果只是单纯需要终端分屏与会话保持，tmux/Zellij 已经够用。</p>]]></content><author><name>Ein Verne</name><email>git@einverne.info</email></author><category term="产品体验" /><category term="coding-agent" /><category term="tmux" /><category term="zellij" /><category term="multiplexer" /><category term="terminal-multiplexer" /><category term="terminal" /><summary type="html"><![CDATA[什么是 Herdr]]></summary></entry></feed>