[{"data":1,"prerenderedAt":2094},["ShallowReactive",2],{"\u002F2026-05-05-otaplan1plan11":3,"\u002F2026-05-05-otaplan1plan11-rel":319},{"id":4,"title":5,"body":6,"column":301,"date":302,"description":12,"extension":303,"hero_image":304,"meta":305,"navigation":306,"path":307,"seo":308,"series_id":304,"severity":304,"stem":309,"summary":310,"tags":311,"__hash__":318},"posts\u002F2026-05-05-OTA从plan1到plan11十一版设计改了什么.md","同一个更新链路，我改了十一版设计",{"type":7,"value":8,"toc":292},"minimark",[9,13,18,30,48,66,80,90,97,101,107,114,129,140,147,151,154,180,183,220,227,230,240,251,254,257,267,270,280,283,286,289],[10,11,12],"p",{},"2026-04-30 到 05-07，OTA 链路从 plan1 走到 plan11，每个版本都在刮之前的设计债。这条线的有意思的地方，不在单个方案本身，而在整体的两个转折：从\"能更新\"转向\"更新失败也能恢复\"，再从\"人工验证\"转向\"故障注入\"。",[14,15,17],"h2",{"id":16},"起点plan1-5-搭主干","起点：plan1-5 搭主干",[10,19,20,24,25,29],{},[21,22,23],"strong",{},"Plan 1"," 是契约的交割。admin-panel 停止持有 OTA 私钥，改为接收 launcher 上报的\"要更新哪个版本\"后，运维丢一个 release-manifest 的 URL 过来，backend 返回 RolloutWrapper（不签名，只是策略提示）。真正的验证权移到客户端：launcher 拿到 unsigned wrapper 后，通过 trust waterfall 去验证 release-manifest 的签名。同时出炉离线签名 CLI ",[26,27,28],"code",{},"wakou-ota-sign","，私钥永不上线。",[10,31,32,35,36,39,40,43,44,47],{},[21,33,34],{},"Plan 2"," 把 launcher 拔到启动链的信任锚点位置。之前 ",[26,37,38],{},"Start-Wakou.cmd"," 直接调 PowerShell 脚本启动 cache，launcher 是被 spawn 的后台进程。现在反过来：",[26,41,42],{},"launcher.exe boot"," 成为启动链主角，它依次调 ps1 prepare cache、spawn panel、启动 guardian。同时集中化 ",[26,45,46],{},"WAKOU_UPDATE_STATE_BASE"," 环境变量，所有涉及 OTA 状态的地方都从这里取路径。",[10,49,50,53,54,57,58,61,62,65],{},[21,51,52],{},"Plan 3"," 锁死 skills 的物理布局。把用户装的各种 skill 统一归到 ",[26,55,56],{},"\u003Croot>\u002Fskills\u002F\u003Cengine>\u002F"," 下，用 NTFS junction 桥接到引擎的默认扫描路径。guardian 同步白名单从 5 条扩到 7 条，加上 ",[26,59,60],{},"skills\\openclaw"," 和 ",[26,63,64],{},"skills\\hermes","。这看似是小改动，但为后续 OTA 主链\"不动 skills\"的假设打好地基。",[10,67,68,71,72,75,76,79],{},[21,69,70],{},"Plan 4"," 把 USB 准入从 stub 变成真实。stage 0 启动时，launcher 通过 Win32 IOCTL 读当前 USB 硬件 serial，用编译期固化的 USB allowlist 公钥验签 USB 上的 ",[26,73,74],{},".config\u002Fwakou-allowlist.json","。新建离线签名 CLI ",[26,77,78],{},"wakou-usb-allowlist-sign","，出厂烧录用。admission reject 直接 exit，stage 0 就杀掉整个启动链。",[10,81,82,85,86,89],{},[21,83,84],{},"Plan 5"," 落地完整 OTA 主链。stage 6 所有者的 ota_check 从 stub 变成真实：调 trust waterfall 拿 release-manifest、HTTP Range resume 下载 zip、sha256+minisign 验证、extract 到本地、robocopy 到 USB、写 version.json 作为提交点。state_machine 从 Idle 推进到 Done，每一步都 fsync state.json。panel 读 env 判断有没有更新，用户点击后 launcher 拉起 ",[26,87,88],{},"--update-only"," 子命令。",[10,91,92,93,96],{},"这五个版本把基础设施搭好了：后端不签名、前端信任瀑布、launcher 当主角、skills 物理拓扑、USB 准入、主链路。",[21,94,95],{},"能跑了","。",[14,98,100],{"id":99},"第一个转折plan6-之后全是容错","第一个转折：plan6 之后全是容错",[10,102,103,106],{},[21,104,105],{},"Plan 6"," 是转折点。标题写的是\"容错 saga + 测试补全\"，但实质是：Plan 5 主链有个致命漏洞——只写了 happy path。如果 download 失败、verify 失败、local apply 一半死、USB 拔掉，state_machine 就卡在中间状态。下次启动时，launcher 不知道这个中间态，stage 1-5 直接跑完，stage 6 又开始 OTA，结果两个 OTA 流同时写 state.json，状态爆炸。",[10,108,109,110,113],{},"Plan 6 把这条断裂线缝好：新增 7 个 resume cell，分别对应 Idle\u002FDownloading\u002FVerifying\u002FLocalApplying\u002FLocalApplied\u002FUsbStaging\u002FUsbSwapping\u002FUsbCommitted\u002FCleanup 这 9 个 phase 中的各个断点。launcher 启动时先读 state.json，看 phase 是啥，直接跳到对应的 resume 分支。比如上次卡在 LocalApplying，这次来了直接从 ",[26,111,112],{},"resume_local_applying"," 开始，继续 extract、trash、install。",[10,115,116,117,120,121,124,125,128],{},"同时引入 trash corrupt 兜底：如果 trash manifest 校验失败（文件被删了或损坏），OTA 拒绝继续，返回 ",[26,118,119],{},"OtaStateCorrupt"," 退出码，让 boot 流程拒绝启动。需要修复的话，跑 ",[26,122,123],{},"launcher.exe --ota-repair"," 收尾。USB 拔盘也错误化了：设个 ",[26,126,127],{},"pending_replug"," flag，记录当时的 USB serial，等用户插回原 USB，resume 检查 serial 一致再继续；serial 不对直接 reject。",[10,130,131,132,135,136,139],{},"从 Plan 6 开始，OTA 设计的所有新增都围绕\"能恢复\"这个主题展开。Plan 7 加后台轮询和设置页，但核心还是——轮询过程中如果出错，不杀进程，error event 写进 ota-events.jsonl，等下次自动重试。Plan 8 加 zip bomb 防护和 trash v2，但归根结底是——compressed 能搞出多大炸弹？单 entry 4GB，多 entry 呢？trash 从 ",[26,133,134],{},"{ paths: Vec }"," 升到 ",[26,137,138],{},"{ entries: Vec\u003C{ path, kind }> }","，让后续 verify 时能准确判断该删啥。",[10,141,142,143,146],{},"这一段的逻辑是",[21,144,145],{},"层层堵漏","：Plan 6 堵 state machine 中间态、trash 损坏、USB 拔盘；Plan 7 堵轮询失败、panel crash；Plan 8 堵 zip bomb、trash 格式模糊。",[14,148,150],{"id":149},"第二个转折plan11-从快照到注入","第二个转折：plan11 从快照到注入",[10,152,153],{},"Plan 6 写了 8 个 cell 测试来覆盖 7 个 resume 分支（还有个 happy path）。但这些 cell 怎么测的？用的是 fixture snapshot——往测试里丢一个特定的 state.json，再丢一个特定的文件树，然后跑 resume 流程，看结果对不对。好处是快，坏处是假——fixture 是死的，真实场景里 download 一半网络断、verify 的 zip 损坏、local apply sync 时 USB I\u002FO error，这些真实的 panic 和 error 在快照里体现不出来。",[10,155,156,159,160,163,164,167,168,171,172,175,176,179],{},[21,157,158],{},"Plan 11"," 把这个反过来：不是\"先造故障状态再恢复\"，而是\"正常跑链路，中途注入故障\"。新建 ",[26,161,162],{},"test_inject"," 模块，一个全局 ",[26,165,166],{},"Mutex\u003COption\u003C(Stage, Fault)>>","。测试侧 ",[26,169,170],{},"arm(Stage::Download, Fault::Transient {…})","，然后跑链路，链路在 download 过程中调 ",[26,173,174],{},"take_for(Stage::Download)","，取出注入的 fault，转成 anyhow error，",[26,177,178],{},"?"," 上抛，整个链继续处理这个错误——resume state、retry、最后要么成功要么进 error 分支。",[10,181,182],{},"这样做的收获是什么？真实的错误路径。Plan 6 的 cell 1（download 失败）现在不是\"丢个 stale state.json\"，而是\"真的跑 http client，中途注入 connection reset，看 resume path 怎么处理\"。io::ErrorKind 对应 Transient（可重试），业务逻辑错误对应 Fatal（不重试）。这两条路在链路里分得清清楚楚。",[10,184,185,186,189,190,193,194,193,197,200,201,204,205,208,209,212,213,216,217,219],{},"而且这套注入框架为 ",[21,187,188],{},"Plan 10"," 的类型化错误做好铺垫。Plan 10 说的是把全链 43 处 ",[26,191,192],{},"anyhow!"," \u002F ",[26,195,196],{},"bail!",[26,198,199],{},".context()"," 统一替换成 typed ",[26,202,203],{},"OtaChainError { Fatal, Transient }","，这样 error event log 就能从一律记\"fatal\"升级成\"fatal 还是 transient\"。Plan 11 的 ",[26,206,207],{},"Fault"," enum 早就是二分的了，Plan 10 只需给它加个 ",[26,210,211],{},".to_chain_error()"," 方法，然后 6 处注入点从 ",[26,214,215],{},".to_anyhow()"," 改成 ",[26,218,211],{}," 即可。",[10,221,222,223,226],{},"整个演变的思路是",[21,224,225],{},"可观测→可测试→可恢复→可调试","。快照测试只能验证\"结果对不对\"；注入测试能验证\"错误路径走得对不对\"；类型化错误让事后分析能从日志直接看出是暂时故障还是永久故障。",[14,228,229],{"id":229},"中间夹的一些扎实细节",[10,231,232,233,236,237,96],{},"plan7 之前的 plan1-6 是基建和容错的两层楼；plan7-11 的后半段在补",[21,234,235],{},"可观测","和",[21,238,239],{},"细节鲁棒性",[10,241,242,243,246,247,250],{},"Plan 7 加的 ",[26,244,245],{},"ota.lock"," 三模式协议（Exclusive \u002F MirrorLease \u002F PendingIntent）是为了防 OTA 和 guardian 同时写 state.json。guardian 跑 robocopy 前先 ",[26,248,249],{},"try_acquire(MirrorLease, timeout=3s)","，改死锁等待为超时快速失败。",[10,252,253],{},"Plan 8 不光加了 zip bomb 防护的 10GB 上限，还把 symlink reject 从隐式（File::create 默认不创建 symlink）变成显式（检查 unix_permissions bit 0o120000，命中就 bail OTA_ZIP_SYMLINK_REJECTED）。",[10,255,256],{},"Plan 9 的 poll-error 缓冲是从 events log 里抽出错误，放进 localStorage 的 50 条 FIFO，让用户在设置页看最近错了什么。从工程角度这是\"让故障可见\"；从产品角度这是\"用户能自救的信息\"。",[10,258,259,260,61,263,266],{},"Plan 10 的 error catalog 用 25 个 const code 给每种错误分类。不是所有 context string 都一样对等，",[26,261,262],{},"SHA256_MISMATCH",[26,264,265],{},"CONNECTION_TIMEOUT"," 是两个完全不同的恢复策略。Plan 10 显式化了这种区分。",[14,268,269],{"id":269},"版本演进的两条线",[10,271,272,273,236,276,279],{},"贯穿始终的是",[21,274,275],{},"架构演进",[21,277,278],{},"鲁棒性演进","两条线。",[10,281,282],{},"架构线：后端契约（plan1）→ launcher 启动链（plan2）→ skills 布局（plan3）→ USB 准入（plan4）→ 主链路（plan5）。这五步是\"能做什么\"。",[10,284,285],{},"鲁棒性线：happy path（plan5）→ resume 主链（plan6）→ 后台轮询+观测（plan7）→ 防护+分类（plan8）→ 错误可见（plan9）→ 错误类型（plan10）→ 故障注入（plan11）。这七步是\"当出错时，系统怎么活下来\"。",[10,287,288],{},"两条线的交点在 Plan 6。plan1-5 是\"system is go\"；plan6-11 是\"system is broken，now what\"。",[10,290,291],{},"这十一个版本压缩到一周多的时间里完成，version bump 这么快的原因就是——每一版都在修上一版的漏。不是说上一版设计有缺陷，而是真实场景的复杂度在逐版暴露出来。plan5 写完以为能跑了，结果 plan6 一加 resume 就发现\"噢，原来还要处理中间态\"。plan6 一写测试就发现\"我的 fixture 不够真实，需要注入\"。这种自洽的迭代，在设计冻结、被迫后测的项目里是看不到的。",{"title":293,"searchDepth":294,"depth":294,"links":295},"",2,[296,297,298,299,300],{"id":16,"depth":294,"text":17},{"id":99,"depth":294,"text":100},{"id":149,"depth":294,"text":150},{"id":229,"depth":294,"text":229},{"id":269,"depth":294,"text":269},"交付与更新","2026-05-05","md",null,{},true,"\u002F2026-05-05-otaplan1plan11",{"title":5,"description":12},"2026-05-05-OTA从plan1到plan11十一版设计改了什么","OTA 链路 11 个设计版本的演进轨迹：从后端签名→前端下载验证→容错恢复→故障注入，两个关键转折点。",[312,313,314,315,316,317],"OTA","launcher","分布式","容错","测试","设计演进","zTJIPC0TMep52bNowC2oy3-v3lxnYBzz8NnXLpcWcjw",[320,727,1601],{"id":321,"title":322,"body":323,"column":301,"date":712,"description":713,"extension":303,"hero_image":304,"meta":714,"navigation":306,"path":715,"seo":716,"series_id":304,"severity":304,"stem":717,"summary":718,"tags":719,"__hash__":726},"posts\u002F2026-08-16-让会话记住目录让回答能停下.md","让会话记住目录，让回答能停下",{"type":7,"value":324,"toc":703},[325,331,335,338,341,344,377,380,388,395,401,405,408,424,427,430,433,437,440,445,463,466,471,480,483,488,497,500,504,507,510,515,521,524,529,538,541,546,555,558,565,570,579,582,587,590,594,597,604,608,611,614,618,621,690,693,699],[10,326,327,328],{},"同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：",[21,329,330],{},"长动作要么在一开始就能定好条件，要么在过程中能叫停。",[14,332,334],{"id":333},"一工作目录原先只活在一次调用里","一、工作目录原先只活在一次调用里",[10,336,337],{},"工具调用要指定工作目录。原先的实现是每次调用各自带一个。",[10,339,340],{},"这在单次调用里没问题，但会话是连续的：用户在一个会话里说「看看这个项目」「改一下那个配置」「跑一下测试」，每次都指定一遍目录，重复且容易出错。",[10,342,343],{},"改成会话级能力。数据模型加两个字段：",[345,346,350],"pre",{"className":347,"code":348,"language":349,"meta":293,"style":293},"language-sql shiki shiki-themes github-light github-dark","-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\nALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\nALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n","sql",[26,351,352,360,365,371],{"__ignoreMap":293},[353,354,357],"span",{"class":355,"line":356},"line",1,[353,358,359],{},"-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\n",[353,361,362],{"class":355,"line":294},[353,363,364],{},"ALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n",[353,366,368],{"class":355,"line":367},3,[353,369,370],{},"-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\n",[353,372,374],{"class":355,"line":373},4,[353,375,376],{},"ALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n",[10,378,379],{},"两个字段的分工是：设备上报它本机的默认目录，会话可以覆盖它。取值优先级：",[345,381,386],{"className":382,"code":384,"language":385},[383],"language-text","会话值 ?? 设备默认值\n","text",[26,387,384],{"__ignoreMap":293},[10,389,390,391,394],{},"迁移只用加列，",[26,392,393],{},"null"," 表示沿用设备默认值，存量会话不受影响。",[10,396,397,398,96],{},"长度上限 512 字符，写在协议包里：",[26,399,400],{},"WORKSPACE_DIR_MAX_LENGTH = 512",[14,402,404],{"id":403},"二四个执行入口共用同一个目录","二、四个执行入口共用同一个目录",[10,406,407],{},"目录信息要传到的位置比想象中多。同一件事在四个地方会被执行：",[409,410,411,415,418,421],"ul",{},[412,413,414],"li",{},"工具调用",[412,416,417],{},"Agent 团队运行",[412,419,420],{},"设备协作",[412,422,423],{},"计划任务",[10,425,426],{},"如果只有工具调用拿到会话目录，其余三个各自回落到设备默认值，用户会遇到「我在会话里设了目录，跑计划任务却跑到了别的地方」。",[10,428,429],{},"所以目录跟着会话走，四个入口都从同一个地方取。",[10,431,432],{},"跨端还有一层：桌面端主进程、预加载层、网页端桥接、聊天界面。四处都要能读能写，用户才能在会话里查看或切换目录。",[14,434,436],{"id":435},"三路径校验的三个坑","三、路径校验的三个坑",[10,438,439],{},"工作目录涉及文件系统，三处安全处理值得单独写。",[10,441,442],{},[21,443,444],{},"相对路径要先解析成绝对路径。",[345,446,450],{"className":447,"code":448,"language":449,"meta":293,"style":293},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n","ts",[26,451,452,458],{"__ignoreMap":293},[353,453,454],{"class":355,"line":356},[353,455,457],{"class":456},"sJ8bj","\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n",[353,459,460],{"class":355,"line":294},[353,461,462],{"class":456},"\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n",[10,464,465],{},"先做正则匹配再解析，等于给相对路径开了一条绕过通道——它们看起来不像敏感路径。",[10,467,468],{},[21,469,470],{},"打开目录前先确认它是目录。",[345,472,474],{"className":447,"code":473,"language":449,"meta":293,"style":293},"\u002F\u002F open-workspace-dir 先 stat 确认是目录才放行，避免经 shell.openPath 执行可执行文件\n",[26,475,476],{"__ignoreMap":293},[353,477,478],{"class":355,"line":356},[353,479,473],{"class":456},[10,481,482],{},"用系统调用打开路径时，如果路径指向一个可执行文件，行为会变成执行它。",[10,484,485],{},[21,486,487],{},"目录不存在时要说明，不能静默回落。",[345,489,491],{"className":447,"code":490,"language":449,"meta":293,"style":293},"\u002F\u002F 换机时会话目录在本机不存在则回落设备默认目录，并在工具结果中明示，不静默。\n",[26,492,493],{"__ignoreMap":293},[353,494,495],{"class":355,"line":356},[353,496,490],{"class":456},[10,498,499],{},"这一条和一个功能细节有关：会话可以跨设备打开。用户在公司电脑上设的目录，回家打开时本机不存在。此时回落是唯一选择，但用户必须知道发生了回落——否则他会以为工具跑在他指定的目录里。",[14,501,503],{"id":502},"四停止生成","四、停止生成",[10,505,506],{},"第二个功能是让流式回答可以中途停止。",[10,508,509],{},"场景很具体：模型开始生成一段长回答，用户看前两段就知道方向不对。原先只能等它写完，或者刷新页面重新提问。",[10,511,512],{},[21,513,514],{},"停止信号走 Redis。",[345,516,519],{"className":517,"code":518,"language":385},[383],"POST \u002Fapi\u002Fchat\u002Fstop\n",[26,520,518],{"__ignoreMap":293},[10,522,523],{},"校验会话归属之后，往 Redis 写一个停止信号。选 Redis 而不是进程内变量，是因为生成可能发生在任意一个 pod 上——信号要跨 pod 生效。同一个会话重复调用是幂等的。",[10,525,526],{},[21,527,528],{},"生成侧轮询读信号。",[345,530,532],{"className":447,"code":531,"language":449,"meta":293,"style":293},"\u002F\u002F 生成侧用节流轮询（300ms）读信号，轮边界与流式回调两处检查点；在途工具跑完再收尾\n",[26,533,534],{"__ignoreMap":293},[353,535,536],{"class":355,"line":356},[353,537,531],{"class":456},[10,539,540],{},"两个检查点：一个是每轮对话的边界，一个是流式回调。工具执行中途不打断——一个已经在跑的工具调用停下来会留下半截状态，等它跑完再收尾。",[10,542,543],{},[21,544,545],{},"停止必须落一条消息。",[345,547,549],{"className":447,"code":548,"language":449,"meta":293,"style":293},"\u002F\u002F 停止一定落一条 assistant 消息并记 stoppedAt，否则前端会一直判成生成中\n",[26,550,551],{"__ignoreMap":293},[353,552,553],{"class":355,"line":356},[353,554,548],{"class":456},[10,556,557],{},"这是被前端状态机决定的。前端的「生成中」判定依据是「最后一条是不是用户消息」，如果停止之后不落一条 assistant 消息，界面会一直显示在生成。",[10,559,560,561,564],{},"数据库加一个字段：",[26,562,563],{},"Message.stoppedAt","。落这条消息时记下停止时刻——刷新、重进会话之后状态不该丢。",[10,566,567],{},[21,568,569],{},"结算按实际用量。",[345,571,573],{"className":447,"code":572,"language":449,"meta":293,"style":293},"\u002F\u002F 结算走原有 settle 路径按实际用量多退少补；被停止的半截回答不进长期记忆\n",[26,574,575],{"__ignoreMap":293},[353,576,577],{"class":355,"line":356},[353,578,572],{"class":456},[10,580,581],{},"半截回答不入长期记忆这一条是必要的。记忆提取的输入是完整的一轮对话，半截内容提取出来的「用户偏好」会是错的。",[10,583,584],{},[21,585,586],{},"继续写。",[10,588,589],{},"停止之后，末条消息上给一个「继续写」。它不是重新生成——从停止的位置接着往下，上下文里包含已经写好的部分。",[14,591,593],{"id":592},"五界面上只有一个按钮","五、界面上只有一个按钮",[10,595,596],{},"生成中，发送按钮变成停止按钮。桌面端和移动端两套布局都要改。",[10,598,599,600,603],{},"这个交互决定了一个细节：停止是一个",[21,601,602],{},"和发送同一位置","的操作。用户在等待时手指本来就停在那个位置，改文案比加一个新按钮更快。",[14,605,607],{"id":606},"六桌面端版本","六、桌面端版本",[10,609,610],{},"这两个功能里，工作目录要桌面端配合。桌面端从 1.0.32 升到 1.0.33。",[10,612,613],{},"顺序上有依赖：云端先上线，桌面端包发出后功能才对用户可见。所以版本号更新是单独一个提交，不是为了记录，是为了标记「从哪个包开始这个功能可用」。",[14,615,617],{"id":616},"七两个功能的共同点","七、两个功能的共同点",[10,619,620],{},"把它们放在一起看，都是「让用户在长动作里保留控制权」：",[622,623,624,639],"table",{},[625,626,627],"thead",{},[628,629,630,633,636],"tr",{},[631,632],"th",{},[631,634,635],{},"工作目录",[631,637,638],{},"停止生成",[640,641,642,654,668,679],"tbody",{},[628,643,644,648,651],{},[645,646,647],"td",{},"控制发生在",[645,649,650],{},"事前指定",[645,652,653],{},"事中叫停",[628,655,656,659,664],{},[645,657,658],{},"数据落点",[645,660,661],{},[26,662,663],{},"Session.workspaceDir",[645,665,666],{},[26,667,563],{},[628,669,670,673,676],{},[645,671,672],{},"跨端要求",[645,674,675],{},"主进程 \u002F 预加载 \u002F 桥接 \u002F 界面",[645,677,678],{},"桌面与移动两套布局",[628,680,681,684,687],{},[645,682,683],{},"不静默的地方",[645,685,686],{},"目录不存在时回落要明示",[645,688,689],{},"停止后必须落消息，界面不能一直显示生成中",[10,691,692],{},"四条对应关系里，最后一行是最容易做错的：两个功能都有「状态没落对，界面就一直显示在跑」的风险。工作目录那条如果静默回落，用户以为跑在 A 目录，实际跑在 B；停止那条如果不落消息，用户以为停了，界面说还在跑。",[10,694,695,696],{},"改法和预想的一样朴素：",[21,697,698],{},"把状态放在能读到的地方，并且让用户看见。",[700,701,702],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":293,"searchDepth":294,"depth":294,"links":704},[705,706,707,708,709,710,711],{"id":333,"depth":294,"text":334},{"id":403,"depth":294,"text":404},{"id":435,"depth":294,"text":436},{"id":502,"depth":294,"text":503},{"id":592,"depth":294,"text":593},{"id":606,"depth":294,"text":607},{"id":616,"depth":294,"text":617},"2026-08-16","同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：长动作要么在一开始就能定好条件，要么在过程中能叫停。",{},"\u002F2026-08-16",{"title":322,"description":713},"2026-08-16-让会话记住目录让回答能停下","两个看似无关的功能：把工作目录从单次工具调用提升为会话级能力；让正在生成的回答可以中途停止并继续。共同点是给长动作加上用户的控制权。",[720,721,722,723,724,725],"桌面端","会话状态","文件系统","路径校验","流式生成","中断","jiUeJkYnicdLCfFjfh09Sim0_nWpiER2NhMrT2FeRKQ",{"id":728,"title":729,"body":730,"column":301,"date":1589,"description":734,"extension":303,"hero_image":304,"meta":1590,"navigation":306,"path":1591,"seo":1592,"series_id":304,"severity":304,"stem":1593,"summary":1594,"tags":1595,"__hash__":1600},"posts\u002F2026-07-07-桌面端只做壳不装页面.md","桌面端只做壳，不装页面",{"type":7,"value":731,"toc":1579},[732,735,738,745,749,752,762,901,907,926,940,960,974,978,984,993,999,1003,1009,1015,1018,1024,1099,1102,1105,1120,1123,1139,1146,1169,1172,1176,1179,1189,1196,1213,1216,1320,1326,1342,1345,1352,1356,1359,1386,1392,1402,1408,1477,1484,1494,1513,1516,1520,1527,1534,1537,1541,1544,1550,1553,1556,1560,1563,1566,1573,1576],[10,733,734],{},"Windows 客户端有两种做法。",[10,736,737],{},"一种是把前端打进安装包，装完之后本地有一份完整页面。另一种是桌面端只做壳，窗口加载远端地址。这条线选了后者。",[10,739,740,741,744],{},"选择的结果是：7 月 7 日一天发了两个版本（1.0.19、1.0.20），两次都只动了 ",[26,742,743],{},"apps\u002Fdesktop"," 自己的代码。",[14,746,748],{"id":747},"一壳里没有页面","一、壳里没有页面",[10,750,751],{},"最直接的证据是渲染进程的产物。",[10,753,754,757,758,761],{},[26,755,756],{},"electron-vite"," 的渲染进程配置指向 ",[26,759,760],{},"src\u002Frenderer\u002Findex.html","，而这个文件全部内容是：",[345,763,767],{"className":764,"code":765,"language":766,"meta":293,"style":293},"language-html shiki shiki-themes github-light github-dark","\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\" \u002F>\n  \u003Ctitle>创玩猩球\u003C\u002Ftitle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Cdiv id=\"app\">Loading...\u003C\u002Fdiv>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n","html",[26,768,769,786,795,804,825,840,850,860,883,892],{"__ignoreMap":293},[353,770,771,775,779,783],{"class":355,"line":356},[353,772,774],{"class":773},"sVt8B","\u003C!",[353,776,778],{"class":777},"s9eBZ","DOCTYPE",[353,780,782],{"class":781},"sScJk"," html",[353,784,785],{"class":773},">\n",[353,787,788,791,793],{"class":355,"line":294},[353,789,790],{"class":773},"\u003C",[353,792,766],{"class":777},[353,794,785],{"class":773},[353,796,797,799,802],{"class":355,"line":367},[353,798,790],{"class":773},[353,800,801],{"class":777},"head",[353,803,785],{"class":773},[353,805,806,809,812,815,818,822],{"class":355,"line":373},[353,807,808],{"class":773},"  \u003C",[353,810,811],{"class":777},"meta",[353,813,814],{"class":781}," charset",[353,816,817],{"class":773},"=",[353,819,821],{"class":820},"sZZnC","\"UTF-8\"",[353,823,824],{"class":773}," \u002F>\n",[353,826,828,830,833,836,838],{"class":355,"line":827},5,[353,829,808],{"class":773},[353,831,832],{"class":777},"title",[353,834,835],{"class":773},">创玩猩球\u003C\u002F",[353,837,832],{"class":777},[353,839,785],{"class":773},[353,841,843,846,848],{"class":355,"line":842},6,[353,844,845],{"class":773},"\u003C\u002F",[353,847,801],{"class":777},[353,849,785],{"class":773},[353,851,853,855,858],{"class":355,"line":852},7,[353,854,790],{"class":773},[353,856,857],{"class":777},"body",[353,859,785],{"class":773},[353,861,863,865,868,871,873,876,879,881],{"class":355,"line":862},8,[353,864,808],{"class":773},[353,866,867],{"class":777},"div",[353,869,870],{"class":781}," id",[353,872,817],{"class":773},[353,874,875],{"class":820},"\"app\"",[353,877,878],{"class":773},">Loading...\u003C\u002F",[353,880,867],{"class":777},[353,882,785],{"class":773},[353,884,886,888,890],{"class":355,"line":885},9,[353,887,845],{"class":773},[353,889,857],{"class":777},[353,891,785],{"class":773},[353,893,895,897,899],{"class":355,"line":894},10,[353,896,845],{"class":773},[353,898,766],{"class":777},[353,900,785],{"class":773},[10,902,903,904,96],{},"没有 ",[26,905,906],{},"\u003Cscript>",[10,908,909,910,913,914,917,918,921,922,925],{},"构建产物三个文件：",[26,911,912],{},"out\u002Fmain\u002Findex.js","（884K）、",[26,915,916],{},"out\u002Fpreload\u002Findex.cjs","（4K）、",[26,919,920],{},"out\u002Frenderer\u002Findex.html","（",[21,923,924],{},"147 字节","）。",[10,927,928,929,193,932,935,936,939],{},"全仓搜索 ",[26,930,931],{},"loadFile",[26,933,934],{},"file:\u002F\u002F","，",[26,937,938],{},"apps\u002Fdesktop\u002Fsrc\u002F"," 下没有任何一处加载本地 renderer。主窗口创建完就一件事：",[345,941,943],{"className":447,"code":942,"language":449,"meta":293,"style":293},"void win.loadURL(cfg.webUrl);\n",[26,944,945],{"__ignoreMap":293},[353,946,947,951,954,957],{"class":355,"line":356},[353,948,950],{"class":949},"szBVR","void",[353,952,953],{"class":773}," win.",[353,955,956],{"class":781},"loadURL",[353,958,959],{"class":773},"(cfg.webUrl);\n",[10,961,962,963,966,967,966,970,973],{},"打包配置里的文件白名单也只列了三项：",[26,964,965],{},"out\u002F**\u002F*","、",[26,968,969],{},"resources\u002F**\u002F*",[26,971,972],{},"package.json","。前端产物不在其中——不是通过某个「排除 web」的配置项排除掉的，是从来没被放进来过。",[14,975,977],{"id":976},"二换来的东西","二、换来的东西",[10,979,980,983],{},[21,981,982],{},"web 发版不动桌面端。"," 页面改动走云端发版，用户下次打开就是新版本，不需要装任何东西。这也是 7 月 7 日能一天发两版的原因：两次改动都只涉及桌面端自己的代码。",[10,985,986,989,990,992],{},[21,987,988],{},"更新包小。"," OTA 只依赖 ",[26,991,743],{}," 源码加一个协议包。更新包的内容就是主进程、预加载、几个本地二进制。",[10,994,995,998],{},[21,996,997],{},"两端的版本可以独立走。"," 没有「前端必须等桌面端一起发」这种依赖。",[14,1000,1002],{"id":1001},"三代价","三、代价",[10,1004,1005,1008],{},[21,1006,1007],{},"离线完全不可用。"," 桌面端的 README 里，「暂未实现」清单下有一行：",[345,1010,1013],{"className":1011,"code":1012,"language":385},[383],"- 离线模式与本地回放\n",[26,1014,1012],{"__ignoreMap":293},[10,1016,1017],{},"没有本地缓存页、没有离线兜底。断网时窗口是空的。",[10,1019,1020,1023],{},[21,1021,1022],{},"首屏依赖网络。"," 主窗口的加载失败处理只做了一件事——标记这次启动是成功的：",[345,1025,1027],{"className":447,"code":1026,"language":449,"meta":293,"style":293},"win.webContents.on(\"did-fail-load\", (_e, _code, _desc, _url, isMainFrame) => {\n  if (isMainFrame) launchGuard.markLaunchSucceeded();\n});\n",[26,1028,1029,1080,1094],{"__ignoreMap":293},[353,1030,1031,1034,1037,1040,1043,1046,1050,1053,1056,1058,1061,1063,1066,1068,1071,1074,1077],{"class":355,"line":356},[353,1032,1033],{"class":773},"win.webContents.",[353,1035,1036],{"class":781},"on",[353,1038,1039],{"class":773},"(",[353,1041,1042],{"class":820},"\"did-fail-load\"",[353,1044,1045],{"class":773},", (",[353,1047,1049],{"class":1048},"s4XuR","_e",[353,1051,1052],{"class":773},", ",[353,1054,1055],{"class":1048},"_code",[353,1057,1052],{"class":773},[353,1059,1060],{"class":1048},"_desc",[353,1062,1052],{"class":773},[353,1064,1065],{"class":1048},"_url",[353,1067,1052],{"class":773},[353,1069,1070],{"class":1048},"isMainFrame",[353,1072,1073],{"class":773},") ",[353,1075,1076],{"class":949},"=>",[353,1078,1079],{"class":773}," {\n",[353,1081,1082,1085,1088,1091],{"class":355,"line":294},[353,1083,1084],{"class":949},"  if",[353,1086,1087],{"class":773}," (isMainFrame) launchGuard.",[353,1089,1090],{"class":781},"markLaunchSucceeded",[353,1092,1093],{"class":773},"();\n",[353,1095,1096],{"class":355,"line":367},[353,1097,1098],{"class":773},"});\n",[10,1100,1101],{},"这个设计的用意是区分「渲染进程起不来」和「网页加载失败」：网页加载失败说明沙箱正常，不该触发降级。但它也意味着主窗口没有错误页——加载失败时用户看到的是一块白窗口。",[10,1103,1104],{},"对比之下，agent 的内置浏览器有兜底页，注释写得很清楚：",[345,1106,1108],{"className":447,"code":1107,"language":449,"meta":293,"style":293},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[26,1109,1110,1115],{"__ignoreMap":293},[353,1111,1112],{"class":355,"line":356},[353,1113,1114],{"class":456},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n",[353,1116,1117],{"class":355,"line":294},[353,1118,1119],{"class":456},"\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[10,1121,1122],{},"主窗口这一条还没补。",[10,1124,1125,1128,1129,966,1132,966,1135,1138],{},[21,1126,1127],{},"两端版本要各自维护兼容边界。"," 这条是最不确定的。目前桌面端和 web 之间只有三个地址契约（",[26,1130,1131],{},"webUrl",[26,1133,1134],{},"apiBase",[26,1136,1137],{},"wsUrl","），没有版本号交换，也没有「桌面端版本过低就拒绝服务」的门槛。",[10,1140,1141,1142,1145],{},"设备注册时会上报 ",[26,1143,1144],{},"appVersion","，云端落库——但它的用途是记录，不是校验。兼容靠的是「能力清单 + 可选字段」：老版本不带某个字段时，新接口按缺省处理。协议包里有对应的测试：",[345,1147,1149],{"className":447,"code":1148,"language":449,"meta":293,"style":293},"it(\"没有工作目录时不带该字段，老桌面端行为不变\", ...)\n",[26,1150,1151],{"__ignoreMap":293},[353,1152,1153,1156,1158,1161,1163,1166],{"class":355,"line":356},[353,1154,1155],{"class":781},"it",[353,1157,1039],{"class":773},[353,1159,1160],{"class":820},"\"没有工作目录时不带该字段，老桌面端行为不变\"",[353,1162,1052],{"class":773},[353,1164,1165],{"class":949},"...",[353,1167,1168],{"class":773},")\n",[10,1170,1171],{},"测试清单里有一条 P1 是「旧桌面端连新后端不崩」。这条列在那里本身就说明它还没有被系统验证过。",[14,1173,1175],{"id":1174},"四壳里真正装的是什么","四、壳里真正装的是什么",[10,1177,1178],{},"如果桌面端什么都不装，它就没必要存在。装的是三类本地运行时。",[10,1180,1181,1184,1185,1188],{},[21,1182,1183],{},"Python 运行时。"," 打包一份 Python 3.12.7 的 embed 版本，让 AI 的 ",[26,1186,1187],{},"python xxx.py"," 在任何客户机器上都能跑，不需要客户自己装 Python。",[10,1190,1191,1192,1195],{},"它必须走 ",[26,1193,1194],{},"extraResources"," 而不是进 asar，配置里写了原因：",[345,1197,1201],{"className":1198,"code":1199,"language":1200,"meta":293,"style":293},"language-js shiki shiki-themes github-light github-dark","\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n","js",[26,1202,1203,1208],{"__ignoreMap":293},[353,1204,1205],{"class":355,"line":356},[353,1206,1207],{"class":456},"\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n",[353,1209,1210],{"class":355,"line":294},[353,1211,1212],{"class":456},"\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n",[10,1214,1215],{},"运行时目录从安装目录解析，也不可能从网络取：",[345,1217,1219],{"className":447,"code":1218,"language":449,"meta":293,"style":293},"const base = join(resourcesPath, \"pyruntime\", sub);\nconst exe = process.platform === \"win32\" ? join(base, \"python.exe\") : join(base, \"bin\", \"python3\");\nif (!existsSync(exe)) return null;\n",[26,1220,1221,1245,1294],{"__ignoreMap":293},[353,1222,1223,1226,1230,1233,1236,1239,1242],{"class":355,"line":356},[353,1224,1225],{"class":949},"const",[353,1227,1229],{"class":1228},"sj4cs"," base",[353,1231,1232],{"class":949}," =",[353,1234,1235],{"class":781}," join",[353,1237,1238],{"class":773},"(resourcesPath, ",[353,1240,1241],{"class":820},"\"pyruntime\"",[353,1243,1244],{"class":773},", sub);\n",[353,1246,1247,1249,1252,1254,1257,1260,1263,1266,1268,1271,1274,1276,1279,1281,1283,1286,1288,1291],{"class":355,"line":294},[353,1248,1225],{"class":949},[353,1250,1251],{"class":1228}," exe",[353,1253,1232],{"class":949},[353,1255,1256],{"class":773}," process.platform ",[353,1258,1259],{"class":949},"===",[353,1261,1262],{"class":820}," \"win32\"",[353,1264,1265],{"class":949}," ?",[353,1267,1235],{"class":781},[353,1269,1270],{"class":773},"(base, ",[353,1272,1273],{"class":820},"\"python.exe\"",[353,1275,1073],{"class":773},[353,1277,1278],{"class":949},":",[353,1280,1235],{"class":781},[353,1282,1270],{"class":773},[353,1284,1285],{"class":820},"\"bin\"",[353,1287,1052],{"class":773},[353,1289,1290],{"class":820},"\"python3\"",[353,1292,1293],{"class":773},");\n",[353,1295,1296,1299,1302,1305,1308,1311,1314,1317],{"class":355,"line":367},[353,1297,1298],{"class":949},"if",[353,1300,1301],{"class":773}," (",[353,1303,1304],{"class":949},"!",[353,1306,1307],{"class":781},"existsSync",[353,1309,1310],{"class":773},"(exe)) ",[353,1312,1313],{"class":949},"return",[353,1315,1316],{"class":1228}," null",[353,1318,1319],{"class":773},";\n",[10,1321,1322,1325],{},[21,1323,1324],{},"VC++ 运行库。"," 安装期静默装，已装则跳过，避免多余的 UAC 弹窗。",[10,1327,1328,1331,1332,966,1335,966,1338,1341],{},[21,1329,1330],{},"Electron 自己的运行库。"," 有一个设计文档专门钉了一份「核心文件清单」：主程序 exe、",[26,1333,1334],{},"ffmpeg.dll",[26,1336,1337],{},"chrome_elf.dll",[26,1339,1340],{},"resources\u002Fapp.asar","，检查发生在安装包解包完成之后、启动新程序之前。",[10,1343,1344],{},"这份清单来自一次真实故障：用户从旧版自动更新之后，启动时报「找不到 ffmpeg.dll」。安装包里其实有这个文件——错误发生在 Electron 主程序加载阶段，那时应用自己的错误处理还没机会运行，所以只能靠安装器检查。",[10,1346,1347,1348,1351],{},"这三类东西的共同点是",[21,1349,1350],{},"它们不能从远端加载","。Python 解释器、运行库 dll、Electron 自身，都必须在本地文件系统上。这才是壳存在的理由，不是为了渲染页面。",[14,1353,1355],{"id":1354},"五发版链路","五、发版链路",[10,1357,1358],{},"四个步骤，脚本里编号就是四段：",[345,1360,1364],{"className":1361,"code":1362,"language":1363,"meta":293,"style":293},"language-bash shiki shiki-themes github-light github-dark","# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n# [4\u002F4] 上传 OTA\n","bash",[26,1365,1366,1371,1376,1381],{"__ignoreMap":293},[353,1367,1368],{"class":355,"line":356},[353,1369,1370],{"class":456},"# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n",[353,1372,1373],{"class":355,"line":294},[353,1374,1375],{"class":456},"# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n",[353,1377,1378],{"class":355,"line":367},[353,1379,1380],{"class":456},"# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n",[353,1382,1383],{"class":355,"line":373},[353,1384,1385],{"class":456},"# [4\u002F4] 上传 OTA\n",[10,1387,1388,1391],{},[21,1389,1390],{},"为什么是「scp 源码到构建机」而不是在构建机上 clone。"," 构建机无法非交互拉取私有仓库（HTTPS 没有 tty，SSH 公钥没授权），所以从工作机把源码推过去，在一个「依赖已装、运行时齐全、密钥在系统环境变量」的固定构建目录里出包。",[10,1393,1394,1397,1398,1401],{},[21,1395,1396],{},"为什么走镜像。"," electron-builder 在国内拉 GitHub releases 会超时，报的是 ",[26,1399,1400],{},"Timeout awaiting 'request' for 600000ms","。构建前设两个镜像环境变量绕开。",[10,1403,1404,1407],{},[21,1405,1406],{},"版本校验有两道。"," 第一道在脚本里，出包之后比对产物清单里的版本和目标版本，不符就中止，不上传：",[345,1409,1411],{"className":1361,"code":1410,"language":1363,"meta":293,"style":293},"[[ \"$ONVER\" == \"$VERSION\" ]] || { echo \"产物版本($ONVER) != 目标($VERSION)，中止上传。\" >&2; exit 1; }\n",[26,1412,1413],{"__ignoreMap":293},[353,1414,1415,1418,1421,1424,1426,1429,1432,1435,1437,1440,1443,1446,1449,1452,1454,1457,1459,1462,1465,1468,1471,1474],{"class":355,"line":356},[353,1416,1417],{"class":773},"[[ ",[353,1419,1420],{"class":820},"\"",[353,1422,1423],{"class":773},"$ONVER",[353,1425,1420],{"class":820},[353,1427,1428],{"class":949}," ==",[353,1430,1431],{"class":820}," \"",[353,1433,1434],{"class":773},"$VERSION",[353,1436,1420],{"class":820},[353,1438,1439],{"class":773}," ]] ",[353,1441,1442],{"class":949},"||",[353,1444,1445],{"class":773}," { ",[353,1447,1448],{"class":1228},"echo",[353,1450,1451],{"class":820}," \"产物版本(",[353,1453,1423],{"class":773},[353,1455,1456],{"class":820},") != 目标(",[353,1458,1434],{"class":773},[353,1460,1461],{"class":820},")，中止上传。\"",[353,1463,1464],{"class":949}," >&2",[353,1466,1467],{"class":773},"; ",[353,1469,1470],{"class":1228},"exit",[353,1472,1473],{"class":1228}," 1",[353,1475,1476],{"class":773},"; }\n",[10,1478,1479,1480,1483],{},"第二道在产物选择里：只允许当前版本的三件套（安装包、blockmap、清单）上传，缺任何一个直接抛错。这一道是为了处理「",[26,1481,1482],{},"dist"," 目录不清、旧版安装包残留」的情况。",[10,1485,1486,1489,1490,1493],{},[21,1487,1488],{},"客户端侧。"," 更新走 ",[26,1491,1492],{},"electron-updater"," 的 generic provider，指向一个静态目录：",[345,1495,1497],{"className":1198,"code":1496,"language":1200,"meta":293,"style":293},"publish: [{ provider: \"generic\", url: updateUrl }],\n",[26,1498,1499],{"__ignoreMap":293},[353,1500,1501,1504,1507,1510],{"class":355,"line":356},[353,1502,1503],{"class":781},"publish",[353,1505,1506],{"class":773},": [{ provider: ",[353,1508,1509],{"class":820},"\"generic\"",[353,1511,1512],{"class":773},", url: updateUrl }],\n",[10,1514,1515],{},"启动时拉清单文件、比较版本、下载、静默安装。",[14,1517,1519],{"id":1518},"六版本号的唯一来源","六、版本号的唯一来源",[10,1521,1522,1523,1526],{},"版本号只在 ",[26,1524,1525],{},"apps\u002Fdesktop\u002Fpackage.json"," 一处。产物名和清单里的版本都由它决定。",[10,1528,1529,1530,1533],{},"与之配套的一条纪律：",[21,1531,1532],{},"绝不能复用旧版本号","。客户端判断「要不要更新」靠的是版本比较，版本号相同就等于没有新版本，用户那边什么都不会发生——而这种失败是静默的，服务端看到的是上传成功。",[10,1535,1536],{},"脚本的这条判断还有一个前置的护栏：工作区有未提交的已跟踪改动时拒绝发版。因为发出去的是工作区源码，不是某个 commit。",[14,1538,1540],{"id":1539},"七一处没对齐的地方","七、一处没对齐的地方",[10,1542,1543],{},"签名。桌面端 README 写的是：",[345,1545,1548],{"className":1546,"code":1547,"language":385},[383],"当前 Windows 安装器未接入 CA 代码签名，用户首次安装可能看到 SmartScreen 提示。\n拿到证书后再通过 WIN_CSC_LINK \u002F WIN_CSC_KEY_PASSWORD 接入签名流程。\n",[26,1549,1547],{"__ignoreMap":293},[10,1551,1552],{},"而发版 skill 里写的是构建机已配 signtool、产出无 SmartScreen 强拦。两处说法不一致，仓库里也搜不到任何签名实现——那两个环境变量只出现在这一行文档里。",[10,1554,1555],{},"这两句话都写在 7 月上旬，中间没有改动过配置。真实状态是：安装包没有 CA 签名，首次安装会出现 SmartScreen 提示。文档里那句「无强拦」应该改掉。",[14,1557,1559],{"id":1558},"八这条路线适合什么","八、这条路线适合什么",[10,1561,1562],{},"选它之前要接受三个前提：用户始终在线、接受首次打开有网络等待、接受两端各自演进。",[10,1564,1565],{},"换来的是发版节奏的自由：改一个页面不需要用户装新版本，改壳里的东西不需要等前端一起走。前者让迭代变快，后者让迭代变危险——OTA 是推给所有人的，很难回滚。",[10,1567,1568,1569,1572],{},"所以这条路线上的纪律集中在一点：",[21,1570,1571],{},"发版前的校验要做足","。两道版本门禁、一条工作区护栏、一个只构建不上传的选项。四道关卡守的都是同一件事：不要把一个身份不明的包推给全部用户。",[10,1574,1575],{},"有一处边界想清楚过：安装包必须包含哪些文件。这份清单不是设计出来的，是被一次「找不到 ffmpeg.dll」的故障逼出来的。清单上的文件缺任何一个，安装器就在启动新程序之前停下来——因为在那个时刻，应用自己的任何补救代码都还没机会运行。",[700,1577,1578],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":293,"searchDepth":294,"depth":294,"links":1580},[1581,1582,1583,1584,1585,1586,1587,1588],{"id":747,"depth":294,"text":748},{"id":976,"depth":294,"text":977},{"id":1001,"depth":294,"text":1002},{"id":1174,"depth":294,"text":1175},{"id":1354,"depth":294,"text":1355},{"id":1518,"depth":294,"text":1519},{"id":1539,"depth":294,"text":1540},{"id":1558,"depth":294,"text":1559},"2026-07-07",{},"\u002F2026-07-07",{"title":729,"description":734},"2026-07-07-桌面端只做壳不装页面","桌面端只加载远端地址，不把前端打进安装包。这条路线换来了独立的发版节奏，代价是离线不可用、首屏依赖网络，以及壳里必须自带的那部分本地运行时。",[1596,720,312,1597,1598,1599],"Electron","electron-builder","打包","版本管理","ElNTxouudoGHuSXPhaMwM-u23sOVeK4HA3GAGBnmito",{"id":1602,"title":1603,"body":1604,"column":301,"date":2082,"description":1608,"extension":303,"hero_image":304,"meta":2083,"navigation":306,"path":2084,"seo":2085,"series_id":304,"severity":304,"stem":2086,"summary":2087,"tags":2088,"__hash__":2093},"posts\u002F2026-06-16-把高风险发版固化成skill.md","不让 AI 碰发版，不等于靠人记住每一步",{"type":7,"value":1605,"toc":2076},[1606,1609,1613,1616,1619,1638,1649,1652,1656,1659,1662,1782,1789,1792,1799,1829,1832,1839,1843,1846,1849,1856,1926,1937,1998,2001,2016,2023,2026,2043,2050,2053,2056,2067,2070,2073],[10,1607,1608],{},"发版从来不应该由人工记忆驱动。5 月那一轮 OTA 系列故障（FA-005 到 FA-008）和跨版本步进导致的漏 DLL 事故反复说明了这一点：发版是不可逆的、影响全部用户的、细节繁琐的操作，任何一个环节遗漏或顺序错误就导致客户端卡死、文件不一致、激活页死循环。我现在不是让 AI 执行发版——那是禁地——而是把 5 个已经踏过所有坑的发版流程全部固化成 skill，让执行者用最小脑力成本跑完完整链路，而不是依赖文档翻译和记忆。",[14,1610,1612],{"id":1611},"触发词不该触发时绝对不触发","触发词：不该触发时绝对不触发",[10,1614,1615],{},"一个 skill 最危险的时刻是被错误调用。\"现在是什么版本\"听起来像发版，\"改一下版本号\"听起来像发版，但它们完全不是。",[10,1617,1618],{},"Wakou 发版 skill 的触发词清单（必读触发词段）就是为了卡死这条线：",[409,1620,1621,1627],{},[412,1622,1623,1624],{},"触发：",[26,1625,1626],{},"发版 \u002F 发布 \u002F release \u002F 打个新版本 \u002F OTA \u002F OTA 死循环 \u002F 版本 bump \u002F \u002Fwakou-release",[412,1628,1629,1630,1633,1634,1637],{},"不触发：",[26,1631,1632],{},"改一下 package.json 版本号","（用户只想改文件），",[26,1635,1636],{},"看看现在是什么版本","（只查询）",[10,1639,1640,1641,1644,1645,1648],{},"反面教材我都经历过。改一下配置文件、改一个环境变量、查一个版本号，因为没有明确的触发词界限，最后莫名其妙走到了发版脚本的某一步。Connector OTA skill 同样列明了不触发场景：",[26,1642,1643],{},"\"Connector 现在是什么版本\"(只查询)"," ",[26,1646,1647],{},"\"改 tauri.conf 版本号\"(用户只想改文件)"," — 这些都不触发，即使命令里含了 version、release 这类关键字。",[10,1650,1651],{},"这条最反直觉但最重要：不该触发时被触发，等于主动送一次误操作机会给自动化流程。",[14,1653,1655],{"id":1654},"步骤不可跳过验证链条硬卡","步骤不可跳过：验证链条硬卡",[10,1657,1658],{},"一旦 skill 确定要执行，每一步都必须有验证关卡，前一步没通过绝不进下一步。我用 Wakou 全自动发版流程作例子。",[10,1660,1661],{},"预检（Step 1）是整个流程的闸门。跑这些 Bash 检查：",[345,1663,1665],{"className":1361,"code":1664,"language":1363,"meta":293,"style":293},"cd \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\ngit status --short                                          # 工作区干净\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass                           # ssh 工具\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign                          # 签名工具\ntest -x \u003C签名 CLI 路径>                                      # 内部签名工具就位\ntest -f \u003C发版签名私钥路径>                                   # 私钥就位\ntest -f \u003CCDN 上传凭据路径>                                   # CDN 凭据就位\n",[26,1666,1667,1675,1689,1703,1715,1742,1761],{"__ignoreMap":293},[353,1668,1669,1672],{"class":355,"line":356},[353,1670,1671],{"class":1228},"cd",[353,1673,1674],{"class":820}," \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\n",[353,1676,1677,1680,1683,1686],{"class":355,"line":294},[353,1678,1679],{"class":781},"git",[353,1681,1682],{"class":820}," status",[353,1684,1685],{"class":1228}," --short",[353,1687,1688],{"class":456},"                                          # 工作区干净\n",[353,1690,1691,1694,1697,1700],{"class":355,"line":367},[353,1692,1693],{"class":1228},"test",[353,1695,1696],{"class":1228}," -x",[353,1698,1699],{"class":820}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass",[353,1701,1702],{"class":456},"                           # ssh 工具\n",[353,1704,1705,1707,1709,1712],{"class":355,"line":373},[353,1706,1693],{"class":1228},[353,1708,1696],{"class":1228},[353,1710,1711],{"class":820}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign",[353,1713,1714],{"class":456},"                          # 签名工具\n",[353,1716,1717,1719,1721,1724,1727,1730,1733,1736,1739],{"class":355,"line":827},[353,1718,1693],{"class":1228},[353,1720,1696],{"class":1228},[353,1722,1723],{"class":949}," \u003C",[353,1725,1726],{"class":820},"签名",[353,1728,1729],{"class":820}," CLI",[353,1731,1732],{"class":820}," 路",[353,1734,1735],{"class":773},"径",[353,1737,1738],{"class":949},">",[353,1740,1741],{"class":456},"                                      # 内部签名工具就位\n",[353,1743,1744,1746,1749,1751,1754,1756,1758],{"class":355,"line":842},[353,1745,1693],{"class":1228},[353,1747,1748],{"class":1228}," -f",[353,1750,1723],{"class":949},[353,1752,1753],{"class":820},"发版签名私钥路",[353,1755,1735],{"class":773},[353,1757,1738],{"class":949},[353,1759,1760],{"class":456},"                                   # 私钥就位\n",[353,1762,1763,1765,1767,1769,1772,1775,1777,1779],{"class":355,"line":852},[353,1764,1693],{"class":1228},[353,1766,1748],{"class":1228},[353,1768,1723],{"class":949},[353,1770,1771],{"class":820},"CDN",[353,1773,1774],{"class":820}," 上传凭据路",[353,1776,1735],{"class":773},[353,1778,1738],{"class":949},[353,1780,1781],{"class":456},"                                   # CDN 凭据就位\n",[10,1783,1784,1785,1788],{},"任何一条 fail，流程停止并告诉用户修什么。如果 git 工作区脏，不是自动 stash，而是问用户：工作区有未提交改动，要先 commit 还是 stash？发版会自动 bump 三个版本文件并 commit。这样做的目的很明确——让用户",[21,1786,1787],{},"清晰意识到","发版会改动工作区，而不是闭着眼睛走入自动流程。",[10,1790,1791],{},"收三个密码（Step 2）也有安全约定。不要把密码记到内存或 commit 进文件，只读 shell 的 export。如果用户在过去几分钟内说过这些密码（在 conversation context 里能直接拿到），可以直接传 env 跑；否则必须用户主动导入。",[10,1793,1794,1795,1798],{},"跑 release.sh（Step 3）用 ",[26,1796,1797],{},"run_in_background=true"," 避免 5-15 分钟阻塞主线，把输出 tee 到日志。然后（Step 4）用 Monitor 工具监控 10 个 stage 的进度：",[1800,1801,1802,1805,1808,1811,1814,1817,1820,1823,1826],"ol",{},[412,1803,1804],{},"preflight",[412,1806,1807],{},"detect FROM_VERSION",[412,1809,1810],{},"tar + scp source to Win build host",[412,1812,1813],{},"build on Win",[412,1815,1816],{},"stage + diff-pack OTA full.zip",[412,1818,1819],{},"minisign full.zip + release-manifest.json",[412,1821,1822],{},"upload to CDN",[412,1824,1825],{},"admin: create release + PATCH rollout",[412,1827,1828],{},"git tag + commit version bump",[10,1830,1831],{},"每个 stage 完成给用户报一句。出现 ✗ FAILED 立刻 surface 错误。没有\"继续试试能不能救\"，就是暴露失败。",[10,1833,1834,1835,1838],{},"这整套链条的目的是",[21,1836,1837],{},"让人工审核点布满全流程","。不是机器自动发版，而是 AI 陪着用户一步步走完发版，每步都看得清、验得出。",[14,1840,1842],{"id":1841},"故障处置内联把坑写在流程里","故障处置内联：把坑写在流程里",[10,1844,1845],{},"OTA 系列故障的教训不应该沉在 bug 复盘里。我把它们直接铺进 skill 的执行逻辑。",[10,1847,1848],{},"Wakou 发版 skill 有整整一章叫「🚨 阻塞性 bug 处置 SOP」。列出了从 G19 到 G31 的 11 个已知 bug：",[10,1850,1851,1852,1855],{},"G19 是 OTA modal 反复弹。原因是 state base 残留 stale ",[26,1853,1854],{},"download.zip.minisig","，当时的修复是：",[345,1857,1859],{"className":1361,"code":1858,"language":1363,"meta":293,"style":293},"# 预检 sanity#3: data\u002F 不删\nsanity#3 强制阻断 deletes 命中 data\u002F。某次发版失败提示 sanity#3 一定是 build 步骤搞坏了 data\u002F 目录的同步，不要绕过 sanity check 去强发，先 debug 为什么 data\u002F 出现在 deletes 列表。\n",[26,1860,1861,1866],{"__ignoreMap":293},[353,1862,1863],{"class":355,"line":356},[353,1864,1865],{"class":456},"# 预检 sanity#3: data\u002F 不删\n",[353,1867,1868,1871,1874,1877,1880,1883,1886,1889,1892,1895,1898,1901,1904,1907,1910,1913,1916,1918,1921,1923],{"class":355,"line":294},[353,1869,1870],{"class":781},"sanity#3",[353,1872,1873],{"class":820}," 强制阻断",[353,1875,1876],{"class":820}," deletes",[353,1878,1879],{"class":820}," 命中",[353,1881,1882],{"class":820}," data\u002F。某次发版失败提示",[353,1884,1885],{"class":820}," sanity#3",[353,1887,1888],{"class":820}," 一定是",[353,1890,1891],{"class":820}," build",[353,1893,1894],{"class":820}," 步骤搞坏了",[353,1896,1897],{"class":820}," data\u002F",[353,1899,1900],{"class":820}," 目录的同步，不要绕过",[353,1902,1903],{"class":820}," sanity",[353,1905,1906],{"class":820}," check",[353,1908,1909],{"class":820}," 去强发，先",[353,1911,1912],{"class":820}," debug",[353,1914,1915],{"class":820}," 为什么",[353,1917,1897],{"class":820},[353,1919,1920],{"class":820}," 出现在",[353,1922,1876],{"class":820},[353,1924,1925],{"class":820}," 列表。\n",[10,1927,1928,1929,1932,1933,1936],{},"G25 是跨用户机器后 chat 权限错。根因是 ",[26,1930,1931],{},"sessions\u002Faudit"," 含绝对路径，guardian sync 扩散。这不是\"修了以后的故事\"，而是",[21,1934,1935],{},"发版前防御 checklist"," 的一部分：",[345,1938,1940],{"className":1361,"code":1939,"language":1363,"meta":293,"style":293},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\nssh CHANCHING@\u003C构建机> 'powershell -Command \"\n  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n    Where-Object { $_.Length -lt 200KB } |\n    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n    Select-Object FullName\n\"'\n",[26,1941,1942,1947,1952,1973,1978,1983,1988,1993],{"__ignoreMap":293},[353,1943,1944],{"class":355,"line":356},[353,1945,1946],{"class":456},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n",[353,1948,1949],{"class":355,"line":294},[353,1950,1951],{"class":456},"# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\n",[353,1953,1954,1957,1960,1962,1965,1968,1970],{"class":355,"line":367},[353,1955,1956],{"class":781},"ssh",[353,1958,1959],{"class":820}," CHANCHING@",[353,1961,790],{"class":949},[353,1963,1964],{"class":820},"构建",[353,1966,1967],{"class":773},"机",[353,1969,1738],{"class":949},[353,1971,1972],{"class":820}," 'powershell -Command \"\n",[353,1974,1975],{"class":355,"line":373},[353,1976,1977],{"class":820},"  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n",[353,1979,1980],{"class":355,"line":827},[353,1981,1982],{"class":820},"    Where-Object { $_.Length -lt 200KB } |\n",[353,1984,1985],{"class":355,"line":842},[353,1986,1987],{"class":820},"    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n",[353,1989,1990],{"class":355,"line":852},[353,1991,1992],{"class":820},"    Select-Object FullName\n",[353,1994,1995],{"class":355,"line":862},[353,1996,1997],{"class":820},"\"'\n",[10,1999,2000],{},"任何一项 fail → 不要 GA，修了再 bump 一个 patch 版本。",[10,2002,2003,2004,2007,2008,2011,2012,2015],{},"Connector OTA skill 的故障字典（§4）则是一张病症与病因的对照表。\"signature verification failed\" 对应的处置是检查 ",[26,2005,2006],{},"TAURI_SIGNING_PRIVATE_KEY"," 是不是 .sec 文件",[21,2009,2010],{},"base64 编码后","的内容。\"OTA 不跨级但用户期望直跳\" 的处置是调整 ",[26,2013,2014],{},"findNextVersion"," 逻辑或在灰度模式放上一个 manifest。",[10,2017,2018,2019,2022],{},"关键在",[21,2020,2021],{},"处置是写死在 skill 里","，不是留在事后复盘让人去翻。OpenClaw Runtime 发版 skill 的故障字典甚至铺了 10 条常见陷阱：BuildKit context 缓存不清导致改动没编进去、gwbridge IP 池满导致 service 无法起、孤儿 docker-proxy 占着 host port、磁盘满导致 openclaw.json 被写成 0 字节。",[10,2024,2025],{},"每一条都带着：\"现象是什么、根因是什么、怎么修\"。拿 BuildKit 缓存那条：",[2027,2028,2029],"blockquote",{},[10,2030,2031,2032,2035,2036,2039,2040,2042],{},"症状：rsync\u002FSFTP 把新源推上 prod repo，源文件 grep 确认是新的，但 docker build 出来的镜像里还是旧 bundle。根因：",[26,2033,2034],{},"--no-cache-filter"," 不可靠，BuildKit 的 build context 层仍可能命中缓存。修法：clawpanel 源改动后，必须用整体 ",[26,2037,2038],{},"--no-cache","，不要用 ",[26,2041,2034],{},"。代价：单次 ~20-30 分钟。但这是唯一能确保改动真编进去的方式。",[10,2044,2045,2046,2049],{},"这不是\"高级技巧\"或\"经验之谈\"。这是",[21,2047,2048],{},"发版的必读须知","，写进 skill 里才能确保每次都不遗漏。",[14,2051,2052],{"id":2052},"为什么这样做有效",[10,2054,2055],{},"高风险操作的安全性来自流程的确定性，不来自执行者的谨慎。人会忘、会打错、会跳步。但固化的流程不会：",[409,2057,2058,2061,2064],{},[412,2059,2060],{},"触发词明确化杀死了\"我不是故意调用发版\"的那类误操作。",[412,2062,2063],{},"步骤验证链条把每一步的前置条件和验收标准写成 Bash 检查，没法绕过。",[412,2065,2066],{},"故障处置内联把一个多月里积累的坑提前封死在流程里，新一轮发版不会重蹈覆辙。",[10,2068,2069],{},"5 个 skill 现在覆盖了：Wakou panel OTA、Connector 桌面端 OTA、OpenClaw runtime 容器全量发布、Codex 助手静默更新、加上客服远程调试的风险边界。它们没有一个是让 AI 执行发版的——都是让 AI 陪着用户，一步步走完一个铺满护栏的流程。",[10,2071,2072],{},"这是把\"不要让 AI 碰发版\"这条禁区，变成了\"AI 执行发版的框架，确保每一步都验证、每个坑都已知、每种故障都有救法\"的可行路径。",[700,2074,2075],{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":293,"searchDepth":294,"depth":294,"links":2077},[2078,2079,2080,2081],{"id":1611,"depth":294,"text":1612},{"id":1654,"depth":294,"text":1655},{"id":1841,"depth":294,"text":1842},{"id":2052,"depth":294,"text":2052},"2026-06-16",{},"\u002F2026-06-16-skill",{"title":1603,"description":1608},"2026-06-16-把高风险发版固化成skill","发版是明确不交给 AI 自主执行的操作。通过触发词精确化、步骤验证链条、故障处置内联，把流程固化到 skill，让高风险操作失败概率趋零。",[2089,312,2090,2091,2092],"发版流程","自动化","Tauri","Docker Swarm","WbjJuf61wSEx4d5Iov2KhPdG1N6hik51RAl8rAGgug8",1789212573562]