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