dsh-dock 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // Host 通信:fetch('dsh-dock/animation/<method>')(见 features/animation/host.js)。
16
16
  import { useState, useEffect, useRef, useCallback, useMemo } from "react";
17
- import { openPanel, panelNav, subscribePanel } from "../../src/shared.js";
17
+ import { openPanel, panelNav, subscribePanel, taskWaiting, taskApprovals, taskStalled } from "../../src/shared.js";
18
18
 
19
19
  // ---------- Host RPC 桥接 ----------
20
20
  function rpcCall(method, args) {
@@ -721,7 +721,11 @@ function MissionDeparture(props) {
721
721
  }
722
722
  function ProgrammingStuck(props) {
723
723
  const waiting = !!props.waiting;
724
- return <div className={"dkan-space-stuck" + (waiting ? " waiting" : "")} aria-hidden="true"><FreighterHull units={3} empty /><span className="dkan-stuck-code"><i />{`{ }`}</span><strong>{waiting ? "等待确认" : "编程等待中"}</strong><small>{truncate((props.task && props.task.title) || "工具暂无新响应", 28)}</small></div>;
724
+ // 等人的性质要说清:等批准(要不要执行)与等回答(选哪个答案)是两件事
725
+ const task = props.task;
726
+ const label = !waiting ? "编程等待中"
727
+ : (taskApprovals(task).length > 0 ? "等待确认" : "等待回答");
728
+ return <div className={"dkan-space-stuck" + (waiting ? " waiting" : "")} aria-hidden="true"><FreighterHull units={3} empty /><span className="dkan-stuck-code"><i />{`{ }`}</span><strong>{label}</strong><small>{truncate((props.task && props.task.title) || "工具暂无新响应", 28)}</small></div>;
725
729
  }
726
730
 
727
731
  // ---------- 全局浮层:轮询 + 动效(功能启用即常驻;通知在 features/notify/) ----------
@@ -889,9 +893,13 @@ export function AnimationOverlay(props) {
889
893
  const phase = active.length > 0
890
894
  ? active.reduce((best, x) => (!best || (x.phaseAt || 0) > (best.phaseAt || 0) ? x : best), null).phase
891
895
  : "think";
892
- // 有会话在等用户确认时,状态文字让位给"等待确认"(优先级最高:不动手就卡住)。
896
+ // 有会话在等用户介入时,状态文字让位给"等待确认 / 等待回答"(优先级最高:不动手就停住)。
893
897
  // 会话级卡片(等待确认/编程等待中)只对"当前正在看的会话"出现,别的会话等待不打扰。
894
- const waitingTask = active.find((x) => Array.isArray(x.approvals) && x.approvals.length > 0) || null;
898
+ // 等人介入的口径统一走 src/shared.js 的 taskWaiting——只认 approvals 会把「提问等回答」
899
+ // 漏成"编程等待中",甚至被卡住判定误报(2026-11-01 用户实测)。
900
+ const waitingTask = active.find((x) => taskWaiting(x)) || null;
901
+ // 等的是批准还是回答:徽标文字跟着变,别一律说「等待确认」把提问吞进去
902
+ const waitWord = waitingTask && taskApprovals(waitingTask).length === 0 ? "等待回答" : "等待确认";
895
903
  const sessionTitleRef = useRef({ at: 0, title: "" });
896
904
  const currentTitle = useMemo(() => {
897
905
  // 标题查询要 getBoundingClientRect(强制布局),限频 1s;面板页/任务行为不受此限制
@@ -903,7 +911,7 @@ export function AnimationOverlay(props) {
903
911
  return c.title;
904
912
  }, [snap.status]);
905
913
  const onScreenTasks = useMemo(() => active.filter((x) => taskOnScreen(x, currentTitle)), [active, currentTitle]);
906
- const waitingTaskOnScreen = onScreenTasks.find((x) => Array.isArray(x.approvals) && x.approvals.length > 0) || null;
914
+ const waitingTaskOnScreen = onScreenTasks.find((x) => taskWaiting(x)) || null;
907
915
  useTicker(ctx, animOn);
908
916
  // 徽标计时:以最早开始的任务为基准
909
917
  const now = Date.now();
@@ -936,7 +944,13 @@ export function AnimationOverlay(props) {
936
944
  const spaceGalaxies = spaceModeOn && spaceDocks ? spaceGalaxyLayout() : null;
937
945
  // Host 在编程阶段持续 18 秒没有 chunk/tool 活动,才把它视为卡住;正常长任务不会误触发。
938
946
  // 卡住卡片同样只对当前会话出现。
939
- const stuckTask = onScreenTasks.find((task) => task.phase === "code" && now - (task.lastActivityAt || task.phaseAt || now) >= 18000) || null;
947
+ // 停在原地等人的会话不算卡住(等批准、等回答都是合法停点,会话零事件是必然的)——
948
+ // 与【任务通知】的卡住判定同一口径:两处都直接调 src/shared.js 的 taskStalled,
949
+ // 它已经把「有在飞的工具调用」「正在压缩上下文」这两种正在干活的安静排除在外
950
+ //(2026-11-02 实测:25 分钟里 18 次误报全落在 compaction 窗口内,最长 96.7 秒)。
951
+ // 别在这里另写一份表达式,否则动画报卡住而通知不报,自相矛盾。
952
+ const stuckTask = onScreenTasks.find((task) => task.phase === "code"
953
+ && taskStalled(task, now, 18000)) || null;
940
954
  const robotScaleFromConfig = Math.max(.85, Math.min(2.2, Number(cfg && cfg.robotScale) || 1.35));
941
955
  const speedStyle = { "--dkan-speed": Number(speed).toFixed(2) };
942
956
  const saveRobotScale = useCallback((robotScale) => {
@@ -1050,7 +1064,7 @@ export function AnimationOverlay(props) {
1050
1064
  ? <AmbientLayer mode={mode} phase={phase} speed={Number(speed).toFixed(2)} tasks={active} galaxies={spaceGalaxies} /> : null}
1051
1065
  {/* 星际模式:空闲时在输入框上方待命;任务卡住时在中央呈现编程等待状态。 */}
1052
1066
  {spaceModeOn && spaceDocks && active.length === 0 && deliveries.length === 0 ? <ReadyFreighter dock={spaceDocks.ready} /> : null}
1053
- {spaceModeOn && stuckTask ? <ProgrammingStuck task={stuckTask} waiting={!!(stuckTask.approvals && stuckTask.approvals.length)} /> : null}
1067
+ {spaceModeOn && stuckTask ? <ProgrammingStuck task={stuckTask} waiting={taskWaiting(stuckTask)} /> : null}
1054
1068
  {!panelOpen ? departures.map((departure) => <MissionDeparture key={departure.id} departure={departure} />) : null}
1055
1069
  {/* 环屏巡航:一颗光点沿屏幕边缘转圈(orbit 模式) */}
1056
1070
  {ambientOn && mode === "orbit" ? <div className="dkan-orbit" style={speedStyle} aria-hidden="true" /> : null}
@@ -1059,7 +1073,7 @@ export function AnimationOverlay(props) {
1059
1073
  {/* 桌面伙伴:背侧视角紧凑双工位人物,阶段与任务同步;整卡可拖到任意位置(robot 模式) */}
1060
1074
  {ambientOn && mode === "robot" ? (
1061
1075
  <div className="dkan-botcard" role="button" tabIndex={0} style={Object.assign({}, botCardStyle, waitingTask ? { "--dkan-phase": "#b45309" } : null)}
1062
- title={active.length + " 个任务 · " + (waitingTask ? "等待确认" : phaseLabel(phase)) + " · 点击查看任务动画页 · 可拖动到任意位置"}
1076
+ title={active.length + " 个任务 · " + (waitingTask ? waitWord : phaseLabel(phase)) + " · 点击查看任务动画页 · 可拖动到任意位置"}
1063
1077
  onPointerDown={onBotPointerDown}
1064
1078
  onPointerMove={onBotPointerMove}
1065
1079
  onPointerUp={onBotPointerUp}
@@ -1068,7 +1082,7 @@ export function AnimationOverlay(props) {
1068
1082
  onKeyDown={(e) => { if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); onBotClick(); } }}>
1069
1083
  <RobotScene phase={phase} tasks={active} />
1070
1084
  <span className="dkan-bot-cap">
1071
- <span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {waitingTask ? "等待确认" : phaseLabel(phase)}
1085
+ <span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {waitingTask ? waitWord : phaseLabel(phase)}
1072
1086
  </span>
1073
1087
  <button type="button" className="dkan-bot-resize" aria-label="调整桌面伙伴大小"
1074
1088
  title="拖动调整桌面伙伴大小"
@@ -1089,14 +1103,14 @@ export function AnimationOverlay(props) {
1089
1103
  {ambientOn && mode !== "robot" ? (
1090
1104
  <button type="button" className={"dkan-badge" + (mode === "breathe" ? " dkan-badge-breathe" : "")}
1091
1105
  style={Object.assign({}, speedStyle, { "--dkan-phase": phaseColor(waitingTask ? "code" : phase) })}
1092
- title={active.length + " 个任务进行中 · " + (waitingTask ? "等待确认" : phaseLabel(phase)) + " · 点击查看任务动画页"}
1106
+ title={active.length + " 个任务进行中 · " + (waitingTask ? waitWord : phaseLabel(phase)) + " · 点击查看任务动画页"}
1093
1107
  onClick={() => openPanel("animation")}>
1094
1108
  <span className="dkan-dotwrap">
1095
1109
  <span className="dkan-dot" />
1096
1110
  {mode === "breathe" ? <span className="dkan-halo" /> : null}
1097
1111
  {mode === "ring" ? <span className="dkan-ring" /> : null}
1098
1112
  </span>
1099
- <span className="dkan-badge-txt"><span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {waitingTask ? "等待确认" : phaseLabel(phase)}</span>
1113
+ <span className="dkan-badge-txt"><span className="n">{active.length}</span> 个任务{elapsed ? " · " + elapsed : ""} · {waitingTask ? waitWord : phaseLabel(phase)}</span>
1100
1114
  </button>
1101
1115
  ) : null}
1102
1116
  {/* 完成瞬间的一次性流光(成功绿 / 异常红),动画结束自动清场 */}
@@ -1261,7 +1275,7 @@ function AnimationView(props) {
1261
1275
  // 运行状态(进行中任务 / 最近完成 / 等待确认)已独立成左侧菜单项,这里只留一句指路
1262
1276
  rows.push(
1263
1277
  <div key="hint" className="dkan-note">
1264
- 任务运行状态(进行中任务、阶段、Token、等待确认与最近完成)已独立成左侧「运行状态」菜单;本页只负责动效。
1278
+ 任务运行状态(进行中任务、阶段、Token、等人介入(等批准 / 等回答)与最近完成)已独立成左侧「运行状态」菜单;本页只负责动效。
1265
1279
  </div>
1266
1280
  );
1267
1281
  return <div className="dkan-root">{rows}</div>;
@@ -125,10 +125,9 @@ function removeRemotePatches(list) {
125
125
  // 回环/安全上下文下 randomUUID 原生存在,脚本自我短路零副作用。
126
126
  const LAN_COMPAT_JS = "(function(){var g=typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:(typeof self!=='undefined'?self:undefined));if(!g)return;if(!g.crypto){try{g.crypto={}}catch(e){return}}var c=g.crypto;var nativeRng=typeof c.getRandomValues==='function'?c.getRandomValues.bind(c):null;function rng(bytes){if(nativeRng){nativeRng(bytes);return bytes}for(var i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256)&255;return bytes}function uuid(){var b=rng(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=[];for(var i=0;i<16;i++)h.push((b[i]+256).toString(16).slice(1));return h.slice(0,4).join('')+'-'+h.slice(4,6).join('')+'-'+h.slice(6,8).join('')+'-'+h.slice(8,10).join('')+'-'+h.slice(10).join('')}function fill(array){rng(array);return array}function define(obj,name,value){if(!obj||typeof obj[name]==='function')return;try{Object.defineProperty(obj,name,{value:value,configurable:true})}catch(e){try{obj[name]=value}catch(e2){}}}define(c,'randomUUID',uuid);if(!nativeRng)define(c,'getRandomValues',fill);define(g.Crypto&&g.Crypto.prototype||null,'randomUUID',uuid);if(!nativeRng)define(g.Crypto&&g.Crypto.prototype||null,'getRandomValues',fill);if(typeof c.randomUUID!=='function'){var fresh={getRandomValues:fill,randomUUID:uuid};if(c.subtle)fresh.subtle=c.subtle;var keyOrigin=Object.create(null);for(var k in c){try{keyOrigin[k]=c[k]}catch(e3){}}for(var k2 in keyOrigin){if(typeof fresh[k2]==='undefined')fresh[k2]=keyOrigin[k2]}try{Object.defineProperty(g,'crypto',{value:fresh,configurable:true})}catch(e4){try{g.crypto=fresh}catch(e5){}}}})()"
127
127
  const LAN_COMPAT_MARKER = 'data-dsh-lan-compat'
128
- // 手机端排版/行为补丁已迁到【手机适配】模块(features/mobile/host.js)——
129
- // 远程访问只保留「账号密码登录的远程入口」本身,窄屏排版与底部 Tab 栏归手机适配。
130
- // 这里只留 LAN 非安全上下文的 crypto.randomUUID 兜底(远程设备经 http://<局域网IP>
131
- // 访问时缺该 API,DSH 客户端引导会整片失败)。
128
+ // 远程访问只注入 LAN 非安全上下文的 crypto.randomUUID 兜底(远程设备经
129
+ // http://<局域网IP> 访问时缺该 API,DSH 客户端引导会整片失败)。
130
+ // 手机端排版/行为(旧【手机适配】)已整体移除,本模块不再注入任何窄屏 chrome。
132
131
  /** 把 LAN 兼容脚本内联注入 index.html 的 <head> 之后(幂等)。 */
133
132
  function injectLanCompat(html) {
134
133
  let out = html
@@ -159,8 +159,7 @@ export function MobileRelayView() {
159
159
  * 【远程访问】不再挂 Overlay:「预览版说明」自动确认已迁到插件级
160
160
  * (src/welcome-ack.js 经 index.js 注册 tapIndex,不随任何功能开关启停)——
161
161
  * 关掉远程访问、主实例直接绑 0.0.0.0 的直连形态同样每次重弹,归属不能在本模块。
162
- * 手机端 chrome(底部 Tab 栏 / 抽屉遮罩 / 面板互斥)归【手机适配】
163
- * (features/mobile/view.js + features/mobile/host.js)。远程访问只负责「远程入口」本身。
162
+ * 手机端排版/行为(旧【手机适配】)已整体移除,远程访问只负责「远程入口」本身。
164
163
  */
165
164
 
166
165
  export function MobileRelayHomeStat() {
@@ -37,7 +37,8 @@ function defaultConfig() {
37
37
  dingtalkWebhook: '',
38
38
  feishuEnabled: false,
39
39
  feishuWebhook: '',
40
- // 语音播报(浏览器 speechSynthesis 短句播报;默认总开关关闭)
40
+ // 语音播报(浏览器 speechSynthesis 短句播报):服务端总开关,默认关闭。
41
+ // 客户端另有按浏览器保存的「本机播报」开关;服务端开 + 本机开才出声。
41
42
  voiceNotify: false,
42
43
  voiceMode: 'brief',
43
44
  voiceOnComplete: true,
@@ -4,17 +4,22 @@
4
4
  // 1. View —— 功能坞面板页:通知事件 / 呈现方式 / 钉钉推送 / 飞书推送 四组设置;
5
5
  // 2. HomeStat —— 首页总揽概要(当前通知事件与推送通道);
6
6
  // 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
7
- // 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等待确认时弹常驻提醒卡,
7
+ // 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等批准 / 提问等回答时弹常驻提醒卡,
8
8
  // 编程阶段长时间无活动弹「卡住」提醒卡(写明主/子任务、停滞时长、最后动作)。
9
+ // 卡住判定的口径统一在 src/shared.js 的 taskStalled:等人介入、命令还在跑、
10
+ // 正在压缩上下文都不算卡住(那都是「在干活」),只有真的安静下来才报。
9
11
  // 所有卡片受「页内卡片」总开关与各类型开关控制(见 View 的通知事件区)。
10
12
  // 结束卡常驻到「看过」(点卡直达会话 / 点进会话 / 手动 ✕),≥3 张叠放收纳。
11
13
  //
12
14
  // 与【任务动画】【运行状态】完全独立:动效在 features/animation/、任务状态一览在
13
15
  // features/runstate/,本模块只管通知(页内卡片 / 提示音 / 系统通知 / 钉钉飞书群机器人推送)。
14
16
  // 模块启停 = 功能坞里的开关(独立菜单项),本页不再有第二个总开关。
17
+ // 语音播报是双层开关:服务端 voiceNotify(settings,跨浏览器)+ 本机开关(localStorage,
18
+ // 当前浏览器),两者都开才播报——团队共用一个 dsh 实例时可按浏览器各自静音。
15
19
  //
16
20
  // Host 通信:fetch('dsh-dock/notify/<method>')(见 features/notify/host.js)。
17
21
  import { useState, useEffect, useRef, useCallback } from "react";
22
+ import { taskWaiting, taskApprovals, taskQuestions, taskStalled, taskActivityAt } from "../../src/shared.js";
18
23
 
19
24
  // ---------- Host RPC 桥接 ----------
20
25
  function rpcCall(method, args) {
@@ -160,6 +165,42 @@ function utterCtor() {
160
165
  export function voiceSupported() {
161
166
  return synthOf() !== null && utterCtor() !== null;
162
167
  }
168
+ // ---------- 本机播报开关(每浏览器独立,存 localStorage) ----------
169
+ // 服务端 voiceNotify 是总开关(跨浏览器/跨设备,宿主 settings 持久化);本机开关是
170
+ // 「这台浏览器要不要出声」的第二层开关——团队共用一个 dsh 实例时,有人想听、有人嫌吵。
171
+ // 两个都开才播报;默认开(保持服务端总开关打开后的既有行为),用户可在本机关闭本机开关。
172
+ const VOICE_CLIENT_KEY = "dsh-dock/notify/voice-client/v1";
173
+ export function voiceClientEnabled() {
174
+ try {
175
+ if (typeof localStorage === "undefined") return true;
176
+ return localStorage.getItem(VOICE_CLIENT_KEY) !== "0";
177
+ } catch { return true; }
178
+ }
179
+ function setVoiceClientEnabled(on) {
180
+ try { if (typeof localStorage !== "undefined") localStorage.setItem(VOICE_CLIENT_KEY, on ? "1" : "0"); } catch { /* 持久化失败仅在本次会话生效 */ }
181
+ }
182
+ /** 真正允许自动播报:服务端总开关 && 本机开关。所有自动播报统一走这里判断。 */
183
+ export function voiceAllowed(cfg) {
184
+ return !!cfg && cfg.voiceNotify === true && voiceClientEnabled();
185
+ }
186
+ /** 本机开关的 React 状态:读写 localStorage,并跟随其它标签页的 storage 事件。 */
187
+ function useVoiceClientEnabled() {
188
+ const [on, setOn] = useState(() => voiceClientEnabled());
189
+ useEffect(() => {
190
+ const sync = () => setOn(voiceClientEnabled());
191
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return undefined;
192
+ window.addEventListener("storage", sync);
193
+ return () => { if (typeof window.removeEventListener === "function") window.removeEventListener("storage", sync); };
194
+ }, []);
195
+ const set = useCallback((next) => {
196
+ const v = next !== false;
197
+ setVoiceClientEnabled(v);
198
+ setOn(v);
199
+ // 关掉本机播报时立刻收声:正在念的那一句不该再继续打扰。
200
+ if (!v) { const s = synthOf(); if (s) { try { s.cancel(); } catch { /* ignore */ } } }
201
+ }, []);
202
+ return [on, set];
203
+ }
163
204
  /** 本机音色表(可能为空:Chrome 首次调用是异步加载,靠 voiceschanged 补)。 */
164
205
  export function localVoices() {
165
206
  const s = synthOf();
@@ -212,7 +253,7 @@ function useLocalVoices() {
212
253
  }
213
254
  let voiceEpoch = 0;
214
255
  /**
215
- * 播一句话(幂等键防重)。cfg 取 voiceNotify 总开关之外的调用方已判过;这里读
256
+ * 播一句话(幂等键防重)。调用方已用 voiceAllowed 判过服务端总开关 + 本机开关;这里读
216
257
  * voiceRate/voiceVolume/voiceLang/voiceURI,voiceURI 在本机不存在时按语言回落,
217
258
  * lang 空时中文文本优先挑中文音色。
218
259
  * @returns 是否真的开始播(不支持/文本为空返回 false)
@@ -334,6 +375,31 @@ export function confirmBroadcastText(task, approval, cfg) {
334
375
  parts.push("正在等待你批准" + (scene ? "执行" + scene : "") + ",工具是" + tool + ",请回到会话处理。");
335
376
  return parts.join("");
336
377
  }
378
+ /**
379
+ * 需回答播报文案(ask_user_question 挂起,等人选答案)。
380
+ * 与「需授权」是两件事:批准是"要不要执行",提问是"要选哪个答案"——播报不能混说。
381
+ * 简洁口径只说「任务需要回答」;适中点名任务与问题;详细再补"共几问"与回到会话的叮嘱。
382
+ */
383
+ export function questionBroadcastText(task, question, cfg) {
384
+ const mode = voiceTone(cfg);
385
+ if (mode === "brief") return "任务需要你回答";
386
+ const who = task && task.title ? "任务「" + truncate(task.title, 30) + "」" : "任务";
387
+ const head = (question && question.header) || "";
388
+ const body = (question && question.question) || "";
389
+ const what = body ? truncate(body.replace(/[#*_`>\[\]()!]/g, ""), 40) : "";
390
+ if (mode === "medium") {
391
+ // 适中口径:点名任务 + 问的是什么(问题正文优先,没有正文退回标题);句号统一补
392
+ const tail = what ? ":" + what : (head ? ":" + head : "");
393
+ return who + "在等你回答" + tail + "。";
394
+ }
395
+ const parts = [];
396
+ if (task && task.title) parts.push("任务:" + truncate(task.title, 30) + "。");
397
+ parts.push(what ? "正在等你回答问题:" + what + "。" : "正在等你回答问题。");
398
+ const count = (question && question.count) || 0;
399
+ if (count > 1) parts.push("共 " + count + " 个问题。");
400
+ parts.push("请回到会话选择答案。");
401
+ return parts.join("");
402
+ }
337
403
  /**
338
404
  * 卡住播报文案:必须回答「哪里卡住了」——主任务还是子任务、哪个会话、停了多久、
339
405
  * 最后在干什么。子任务静默会把「主任务明明在跑却突然播报」放大成困惑(2026-10-30
@@ -358,8 +424,14 @@ export function stuckBroadcastText(task, cfg) {
358
424
  return parts.join("");
359
425
  }
360
426
 
361
- /** 卡住判定阈值:与【任务动画】同款——phase=code 且 18 秒无活动视为卡住(正常长任务不误报)。 */
427
+ /** 卡住判定阈值:与【任务动画】同款——phase=code 且这么久没动静才视为卡住(正常长任务不误报)。
428
+ * 「没动静」由 src/shared.js 的 taskStalled 定义:等人介入、命令在跑、正在压缩上下文都算在干活。 */
362
429
  const STUCK_MS = 18000;
430
+ /** 读取卡住阈值(宿主可下发 stuckMs 覆盖,缺字段回退 STUCK_MS;播报与判定共用同一口径)。 */
431
+ function stuckMsOf(cfg) {
432
+ const v = cfg && Number(cfg.stuckMs);
433
+ return Number.isFinite(v) && v > 0 ? v : STUCK_MS;
434
+ }
363
435
 
364
436
  // ---------- 共享快照:浮层(轮询) / 面板页 / 首页概要共用一份数据 ----------
365
437
  const notifyStore = {
@@ -533,6 +605,7 @@ export function NotifyOverlay(props) {
533
605
  const [stackHover, setStackHover] = useState(false); // 卡片栈被悬停(叠放时整体展开)
534
606
  const prevActiveRef = useRef(null);
535
607
  const remindedApprovalsRef = useRef(new Map()); // sessionId -> 提醒过的 approvalId 集合
608
+ const remindedQuestionsRef = useRef(new Map()); // sessionId -> 提醒过的提问 callId 集合
536
609
  const toastSeqRef = useRef(0);
537
610
 
538
611
  const pushToast = useCallback((toast) => {
@@ -598,15 +671,16 @@ export function NotifyOverlay(props) {
598
671
  if (!activeIds.has(sid)) remindedApprovalsRef.current.delete(sid);
599
672
  }
600
673
  for (const task of st.active) {
601
- const approvals = Array.isArray(task.approvals) ? task.approvals : [];
674
+ const approvals = taskApprovals(task);
602
675
  if (approvals.length === 0) continue;
603
676
  let seen = remindedApprovalsRef.current.get(task.sessionId);
604
677
  if (!seen) { seen = new Set(); remindedApprovalsRef.current.set(task.sessionId, seen); }
605
678
  const fresh = approvals.filter((a) => a && !seen.has(a.id));
606
679
  if (fresh.length === 0) continue;
607
680
  for (const a of fresh) seen.add(a.id);
608
- // 语音播报走自己的开关(在卡片开关判定**之前**:关掉卡片提醒不等于关掉耳朵)
609
- if (cfg.voiceNotify === true && cfg.voiceOnConfirm !== false) {
681
+ // 语音播报走自己的开关(在卡片开关判定**之前**:关掉卡片提醒不等于关掉耳朵)。
682
+ // 服务端总开关 + 本机开关都开才出声(团队共用一个实例时各自可静音)。
683
+ if (voiceAllowed(cfg) && cfg.voiceOnConfirm !== false) {
610
684
  speakVoice(confirmBroadcastText(task, fresh[0], cfg), cfg);
611
685
  }
612
686
  // 提醒开关在"收到新确认项"时检查,保证开关中途打开对下一项生效
@@ -650,22 +724,87 @@ export function NotifyOverlay(props) {
650
724
  }
651
725
  }, [snap.status]);
652
726
 
653
- // 审批项消失(approval/decided 后宿主把它移出 approvals)→ 对应需确认卡自动收起。
654
- // 只标记 closing 走淡出,不直接删:与手动点 ✕ 的退场一致。
655
- // 卡片绑定的 approvalKeys 全部不在仍待确认的集合里才收;空 keys(老数据)不动。
727
+ // ===== 需回答检测:会话调 ask_user_question 后挂起等人选答案时通知 =====
728
+ // 与「需确认」分开成一段:来源不同(tool/call → tool/result,而非 approval/asked →
729
+ // decided),文案也不同("要你选答案"不是"要你批准执行")。但语义同属「任务停在原地等人」,
730
+ // 因此卡住判定把它们一并排除(见下方 stuck 检测的 taskWaiting 判断)。
731
+ // 真实事故(2026-11-01 用户反馈):提问挂起期间会话零事件,18 秒后被判成「主任务卡住了」,
732
+ // 界面一边显示提问表单、一边报卡住——错的不是检测阈值,而是"等人"没被算作一种合法停点。
733
+ useEffect(() => {
734
+ const st = snap.status;
735
+ if (!st || !st.active) return;
736
+ const cfg = st.config || {};
737
+ const activeIds = new Set(st.active.map((x) => x.sessionId));
738
+ for (const [sid, seen] of remindedQuestionsRef.current) {
739
+ if (!activeIds.has(sid)) remindedQuestionsRef.current.delete(sid);
740
+ }
741
+ for (const task of st.active) {
742
+ const questions = taskQuestions(task);
743
+ if (questions.length === 0) continue;
744
+ let seen = remindedQuestionsRef.current.get(task.sessionId);
745
+ if (!seen) { seen = new Set(); remindedQuestionsRef.current.set(task.sessionId, seen); }
746
+ const fresh = questions.filter((q) => q && q.id && !seen.has(q.id));
747
+ if (fresh.length === 0) continue;
748
+ for (const q of fresh) seen.add(q.id);
749
+ // 语音播报走自己的开关(与卡片开关正交:关卡片不等于关掉耳朵)。
750
+ // 服务端总开关 + 本机开关都开才出声。
751
+ if (voiceAllowed(cfg) && cfg.voiceOnConfirm !== false) {
752
+ speakVoice(questionBroadcastText(task, fresh[0], cfg), cfg);
753
+ }
754
+ // 提醒开关在"收到新提问"时检查,保证开关中途打开对下一问生效
755
+ if (cfg.notifyOnConfirm === false) continue;
756
+ const tone = cardTone(cfg);
757
+ const head = fresh[0].header || "";
758
+ const body = fresh[0].question || "";
759
+ const lines = ["任务:" + (task.title || "(无标题)")];
760
+ if (body) lines.push((head ? head + ":" : "问题:") + truncate(body, 140));
761
+ else if (head) lines.push("问题:" + head);
762
+ const count = (fresh[0].count || 0);
763
+ if (count > 1) lines.push(tone === "brief" ? "共 " + count + " 个问题" : "共 " + count + " 个问题等你选择");
764
+ if (tone === "detailed") lines.push("请在会话里选好答案后继续");
765
+ pushToast({
766
+ id: ++toastSeqRef.current,
767
+ kind: "confirm",
768
+ title: "任务需要你回答",
769
+ body: lines.join("\n"),
770
+ // 点卡片直达该会话去选答案(与需确认卡同哲学:问题没答完卡片就该在)
771
+ sessionId: task.sessionId || "",
772
+ sessionTitle: task.title || "",
773
+ // 与这批提问绑定:回答落地后宿主把它们移出 questions,下面的对账 effect 据此收起
774
+ questionKeys: fresh.map((q) => task.sessionId + "|" + q.id),
775
+ });
776
+ if (cfg.soundNotify !== false) playAskSound(cfg.soundEffect);
777
+ if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
778
+ && typeof Notification !== "undefined" && Notification.permission === "granted") {
779
+ try {
780
+ new Notification("dsh 任务需要你回答", {
781
+ body: (task.title || "(无标题)") + " · " + (head || "有一问在等你选择"),
782
+ tag: "dsh-dock-question-" + task.sessionId,
783
+ });
784
+ } catch { /* 系统通知失败不影响页面内通知 */ }
785
+ }
786
+ }
787
+ }, [snap.status]);
788
+
789
+ // 等人介入的项消失(approval/decided 移出 approvals,或回答落地移出 questions)
790
+ // → 对应提醒卡自动收起。只标记 closing 走淡出,不直接删:与手动点 ✕ 的退场一致。
791
+ // 卡片绑定的 keys 全部不在仍在等待的集合里才收;空 keys(老数据)不动。
792
+ // 两类卡共用这段对账:kind=confirm 的卡片要么绑 approvalKeys、要么绑 questionKeys。
656
793
  useEffect(() => {
657
794
  const st = snap.status;
658
795
  if (!st || !st.active) return;
659
796
  const live = new Set();
660
797
  for (const task of st.active) {
661
- const approvals = Array.isArray(task.approvals) ? task.approvals : [];
662
- for (const a of approvals) if (a && a.id) live.add(task.sessionId + "|" + a.id);
798
+ for (const a of taskApprovals(task)) if (a && a.id) live.add(task.sessionId + "|" + a.id);
799
+ for (const q of taskQuestions(task)) if (q && q.id) live.add(task.sessionId + "|" + q.id);
663
800
  }
664
801
  setToasts((prev) => {
665
802
  let changed = false;
666
803
  const next = prev.map((x) => {
667
804
  if (x.kind !== "confirm" || x.closing) return x;
668
- const keys = Array.isArray(x.approvalKeys) ? x.approvalKeys : [];
805
+ const keys = []
806
+ .concat(Array.isArray(x.approvalKeys) ? x.approvalKeys : [])
807
+ .concat(Array.isArray(x.questionKeys) ? x.questionKeys : []);
669
808
  if (keys.length === 0 || keys.some((k) => live.has(k))) return x;
670
809
  changed = true;
671
810
  return Object.assign({}, x, { closing: true });
@@ -726,20 +865,27 @@ export function NotifyOverlay(props) {
726
865
  if (t.isSubtask && t.parentSessionId) parentsWaitingOnSubtask.add(t.parentSessionId);
727
866
  }
728
867
  for (const task of st.active) {
729
- // 等用户批准的会话不算卡住——那是「需确认」提醒的职责,别重复轰炸
730
- const approvals = Array.isArray(task.approvals) ? task.approvals : [];
731
- if (approvals.length > 0) { stuckSeenRef.current.delete(task.sessionId); continue; }
868
+ // 停在原地等人的会话不算卡住——那是「需确认 / 需回答」提醒的职责,别重复轰炸。
869
+ // 两类都要排除:① 工具等批准(approvals);② 提问等回答(questions)。
870
+ // ②是 2026-11-01 用户实测的误报来源:会话调 ask_user_question 后挂起等人选答案,
871
+ // 其间会话一条事件都没有,18 秒安静被当成停滞,播报「主任务卡住了」并弹卡——
872
+ // 界面上明明是在等用户回答(截图为证),报「卡住」完全是错的口径。
873
+ if (taskWaiting(task)) { stuckSeenRef.current.delete(task.sessionId); continue; }
732
874
  if (task.phase !== "code") { continue; }
733
875
  // 该会话有活跃子任务在跑 → 它在等子任务,不算卡住(否则主任务进行中会误报卡住)
734
876
  if (parentsWaitingOnSubtask.has(task.sessionId)) continue;
735
- const lastAct = task.lastActivityAt || task.phaseAt || 0;
736
- if (!lastAct || now - lastAct < STUCK_MS) continue;
877
+ // 卡住判定统一走 src/shared.js 的 taskStalled:它已经排除「有在飞的工具调用」与
878
+ // 「正在压缩上下文」这两种**正在干活**的安静。2026-11-02 用户实测:25 分钟里 18 次
879
+ // 误报全落在 compaction 窗口内(最长 96.7 秒),界面在刷「上下文已压缩」却报卡住。
880
+ // 停点指纹改用同一个活动表达式(taskActivityAt),对账 effect 必须一致,否则卡片闪。
881
+ const lastAct = taskActivityAt(task);
882
+ const stuckMs = taskStalled(task, now, stuckMsOf(cfg));
883
+ if (!stuckMs) continue;
737
884
  const prev = stuckSeenRef.current.get(task.sessionId);
738
885
  if (prev === lastAct) continue; // 同一停点已报过
739
886
  stuckSeenRef.current.set(task.sessionId, lastAct);
740
- const stuckMs = now - lastAct;
741
- // —— 语音:说清主/子任务、标题、停滞时长与最后动作 ——
742
- if (cfg.voiceNotify === true && cfg.voiceOnStuck !== false) {
887
+ // —— 语音:说清主/子任务、标题、停滞时长与最后动作(服务端总开关 + 本机开关)——
888
+ if (voiceAllowed(cfg) && cfg.voiceOnStuck !== false) {
743
889
  speakVoice(stuckBroadcastText(task, Object.assign({}, cfg, { stuckMs: stuckMs })), cfg);
744
890
  }
745
891
  // —— 卡片:只受「卡住提醒」与「页内卡片」总开关控制(总开关在 pushToast 里把关)——
@@ -771,8 +917,10 @@ export function NotifyOverlay(props) {
771
917
  }, [snap.status]);
772
918
 
773
919
  // 卡住卡的自动收起(与需确认卡同哲学:不设倒计时,状态没变就一直挂着):
774
- // 恢复活动(lastActivityAt 变了)/ 离开活跃列表(结束或取消)/ 转为等审批
775
- // (需确认卡接管)→ 标记 closing 走同一套 240ms 淡出。只标记不直删,与手动 ✕ 退场一致。
920
+ // 恢复活动 / 离开活跃列表(结束或取消)/ 转为等人介入(需确认卡或需回答卡接管)
921
+ // → 标记 closing 走同一套 240ms 淡出。只标记不直删,与手动 ✕ 退场一致。
922
+ // 「恢复活动」的判据必须与卡住判定处**完全一致**(都走 taskActivityAt),否则同一停点
923
+ // 两处算出的指纹不同,卡片会反复收起又弹出。
776
924
  useEffect(() => {
777
925
  const st = snap.status;
778
926
  if (!st || !st.active) return;
@@ -783,8 +931,8 @@ export function NotifyOverlay(props) {
783
931
  if (x.kind !== "stuck" || x.closing) return x;
784
932
  const task = byId.get(x.sessionId);
785
933
  const stillStalled = !!task
786
- && (task.lastActivityAt || task.phaseAt || 0) === x.stuckAt
787
- && (Array.isArray(task.approvals) ? task.approvals.length : 0) === 0;
934
+ && taskActivityAt(task) === x.stuckAt
935
+ && !taskWaiting(task);
788
936
  if (stillStalled) return x;
789
937
  changed = true;
790
938
  return Object.assign({}, x, { closing: true });
@@ -799,8 +947,8 @@ export function NotifyOverlay(props) {
799
947
  const info = endInfo(reason);
800
948
  const wanted = success ? cfg.notifyOnComplete : cfg.notifyOnError;
801
949
  // 语音播报先走自己的开关(与卡片开关正交:关卡片不关耳朵)。
802
- // 播报音量归 0 / 浏览器不支持时静默跳过即可。
803
- if (cfg.voiceNotify === true
950
+ // 服务端总开关 + 本机开关都开才出声;播报音量归 0 / 浏览器不支持时静默跳过即可。
951
+ if (voiceAllowed(cfg)
804
952
  && (success ? cfg.voiceOnComplete !== false : cfg.voiceOnError !== false)) {
805
953
  speakVoice(endBroadcastText(success, record, task, cfg), cfg);
806
954
  }
@@ -876,6 +1024,7 @@ export function NotifyOverlay(props) {
876
1024
  function NotifyView(props) {
877
1025
  const snap = useNotify();
878
1026
  const voices = useLocalVoices(); // 音色下拉用(本机 speechSynthesis 音色表)
1027
+ const [voiceClientOn, setVoiceClientOn] = useVoiceClientEnabled(); // 本机播报开关(localStorage)
879
1028
  const [cfg, setCfg] = useState(null); // null = 尚未加载
880
1029
  const [saveErr, setSaveErr] = useState("");
881
1030
  const [testing, setTesting] = useState(false);
@@ -1048,7 +1197,7 @@ function NotifyView(props) {
1048
1197
  onClick={() => patch({ notifyOnConfirm: cfg.notifyOnConfirm === false })}>
1049
1198
  {cfg.notifyOnConfirm !== false ? "开" : "关"}
1050
1199
  </button>
1051
- <span className="dknt-row-sub">工具等待你批准时弹卡片并响确认音;审批处理完自动收起,没处理完就一直显示(点卡片直达会话)</span>
1200
+ <span className="dknt-row-sub">工具等你批准、或会话提问等你选答案时弹卡片并响确认音;处理完自动收起,没处理完就一直显示(点卡片直达会话)。这两种「停在原地等人」都不算卡住</span>
1052
1201
  </div>
1053
1202
  <div className="dknt-row">
1054
1203
  <span className="dknt-row-label">卡住提醒</span>
@@ -1056,7 +1205,7 @@ function NotifyView(props) {
1056
1205
  onClick={() => patch({ notifyOnStuck: cfg.notifyOnStuck === false })}>
1057
1206
  {cfg.notifyOnStuck !== false ? "开" : "关"}
1058
1207
  </button>
1059
- <span className="dknt-row-sub">编程阶段 18 秒没有动静时弹卡片,写明是主任务还是子任务、停滞了多久、最后在执行什么;恢复活动 / 任务结束自动收起,同一停点只弹一次(语音播报走「语音播报」区的卡住开关,互不干扰)</span>
1208
+ <span className="dknt-row-sub">编程阶段长时间没有动静时弹卡片,写明是主任务还是子任务、停滞了多久、最后在执行什么;恢复活动 / 任务结束自动收起,同一停点只弹一次。等批准、等回答、命令还在跑、正在压缩上下文的会话都不算卡住(那些都是在干活,别重复轰炸)(语音播报走「语音播报」区的卡住开关,互不干扰)</span>
1060
1209
  </div>
1061
1210
  </div>
1062
1211
  </div>,
@@ -1120,15 +1269,25 @@ function NotifyView(props) {
1120
1269
  <div key="voice" className="dknt-sec">
1121
1270
  <div className="dknt-sec-head">
1122
1271
  <span className="dknt-sec-title">语音播报</span>
1123
- <span className="dknt-sec-sub">任务完成 / 停止 / 需要授权 / 卡住时用浏览器语音(TTS)说一句话;与提示音、系统通知互不冲突</span>
1272
+ <span className="dknt-sec-sub">任务完成 / 停止 / 需要授权 / 需回答 / 卡住时用浏览器语音(TTS)说一句话;服务端总开关(跨浏览器)与本机开关(当前浏览器)都开启才播报</span>
1124
1273
  <span className="dknt-sec-sw">
1125
- <span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "已开启" : "已关闭"}</span>
1274
+ <span className={"dknt-sec-swlabel" + (cfg.voiceNotify ? " on" : "")}>{cfg.voiceNotify ? "服务端:已开启" : "服务端:已关闭"}</span>
1126
1275
  <button type="button" className={"dock-sw" + (cfg.voiceNotify ? " on" : "")}
1127
- role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报"
1128
- title={cfg.voiceNotify ? "关闭语音播报" : "开启语音播报"}
1276
+ role="switch" aria-checked={cfg.voiceNotify} aria-label="开关语音播报(服务端总开关)"
1277
+ title={cfg.voiceNotify ? "关闭语音播报(服务端总开关)" : "开启语音播报(服务端总开关)"}
1129
1278
  onClick={() => patch({ voiceNotify: !cfg.voiceNotify })} />
1130
1279
  </span>
1131
1280
  </div>
1281
+ <div className="dknt-row">
1282
+ <span className="dknt-row-label">本机播报</span>
1283
+ <button type="button" className={"dknt-miniswitch" + (voiceClientOn ? " on" : "")}
1284
+ role="switch" aria-checked={voiceClientOn} aria-label="开关本机语音播报"
1285
+ title={voiceClientOn ? "关闭本机语音播报" : "开启本机语音播报"}
1286
+ onClick={() => setVoiceClientOn(!voiceClientOn)}>{voiceClientOn ? "开" : "关"}</button>
1287
+ <span className="dknt-row-sub">{voiceClientOn
1288
+ ? "本浏览器会播报(设置保存在当前浏览器;服务端总开关关闭时不生效)"
1289
+ : "本浏览器不播报(设置保存在当前浏览器,不影响其他浏览器)"}</span>
1290
+ </div>
1132
1291
  {cfg.voiceNotify ? <div className="dknt-rows-narrow">
1133
1292
  <div className="dknt-row">
1134
1293
  <span className="dknt-row-label">播报口径</span>
@@ -1147,12 +1306,12 @@ function NotifyView(props) {
1147
1306
  {[
1148
1307
  ["voiceOnComplete", "完成", cfg.voiceOnComplete !== false],
1149
1308
  ["voiceOnError", "停止", cfg.voiceOnError !== false],
1150
- ["voiceOnConfirm", "需授权", cfg.voiceOnConfirm !== false],
1309
+ ["voiceOnConfirm", "需授权/需回答", cfg.voiceOnConfirm !== false],
1151
1310
  ["voiceOnStuck", "卡住", cfg.voiceOnStuck !== false],
1152
1311
  ].map(([key, label, on]) => (
1153
1312
  <button type="button" key={key} className={"dknt-miniswitch" + (on ? " on" : "")}
1154
1313
  style={{ marginRight: 6 }}
1155
- title={key === "voiceOnStuck" ? "编程阶段 18 秒没有动静时播报一次(恢复后可再报)" : ""}
1314
+ title={key === "voiceOnStuck" ? "编程阶段长时间没有动静时播报一次(恢复后可再报;命令在跑 / 正在压缩上下文不算)" : ""}
1156
1315
  onClick={() => patch({ [key]: !on })}>{label}{on ? "·开" : "·关"}</button>
1157
1316
  ))}
1158
1317
  </span>
@@ -1230,7 +1389,7 @@ function NotifyView(props) {
1230
1389
  </select>
1231
1390
  <span className="dknt-row-sub">未指定音色时按语言自动挑;已选音色的语言会覆盖这里</span>
1232
1391
  </div>
1233
- </div> : <div className="dknt-note">未开启——任务事件不播报语音(提示音、系统通知不受影响)。</div>}
1392
+ </div> : <div className="dknt-note">服务端总开关未开启——任务事件不播报语音(提示音、系统通知不受影响);上面的「本机播报」只决定本浏览器是否出声。</div>}
1234
1393
  </div>,
1235
1394
  <div key="dingtalk" className="dknt-sec">
1236
1395
  <div className="dknt-sec-head">
@@ -1315,10 +1474,10 @@ function NotifyView(props) {
1315
1474
  );
1316
1475
  }
1317
1476
  if (saveErr) rows.push(<div key="err" className="dknt-note dknt-err">{saveErr}</div>);
1318
- // 任务运行状态(进行中 / 最近完成 / 等待确认)已独立成左侧「运行状态」菜单项,这里只留一句指路
1477
+ // 任务运行状态(进行中 / 最近完成 / 等人介入)已独立成左侧「运行状态」菜单项,这里只留一句指路
1319
1478
  rows.push(
1320
1479
  <div key="hint" className="dknt-note">
1321
- 任务运行状态(进行中任务、等待确认与最近完成)已独立成左侧「运行状态」菜单;本页只负责结束与需确认时的提醒。
1480
+ 任务运行状态(进行中任务、等人介入(等批准 / 等回答)与最近完成)已独立成左侧「运行状态」菜单;本页只负责结束与需确认 / 需回答时的提醒。
1322
1481
  </div>
1323
1482
  );
1324
1483
  return <div className="dknt-root">{rows}</div>;
@@ -1332,8 +1491,10 @@ function NotifyStat(props) {
1332
1491
  const cfg = snap.status && snap.status.config;
1333
1492
  if (snap.error) return <span className="dknt-err">通知状态不可用(宿主需重启加载通知路由)</span>;
1334
1493
  if (!cfg) return <span>等待通知配置…</span>;
1335
- const events = [cfg.notifyOnComplete ? "完成" : "", cfg.notifyOnError ? "异常" : "", cfg.notifyOnConfirm !== false ? "需确认" : ""].filter(Boolean);
1336
- const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : "", cfg.voiceNotify ? ("语音播报" + (cfg.voiceMode === "detailed" ? "·详" : cfg.voiceMode === "medium" ? "·适" : "·简")) : ""].filter(Boolean);
1494
+ const events = [cfg.notifyOnComplete ? "完成" : "", cfg.notifyOnError ? "异常" : "", cfg.notifyOnConfirm !== false ? "需确认/需回答" : ""].filter(Boolean);
1495
+ const voiceMode = cfg.voiceMode === "detailed" ? "·详" : cfg.voiceMode === "medium" ? "·适" : "·简";
1496
+ const voice = !cfg.voiceNotify ? "" : (voiceClientEnabled() ? "语音播报" + voiceMode : "语音播报·本机已关");
1497
+ const extras = [cfg.soundNotify !== false ? "提示音" : "", cfg.systemNotify ? "系统通知" : "", voice].filter(Boolean);
1337
1498
  const push = [cfg.dingtalkEnabled ? "钉钉" : "", cfg.feishuEnabled ? "飞书" : ""].filter(Boolean);
1338
1499
  return <span>{(events.length ? "通知:" + events.join("/") : "通知事件全关")
1339
1500
  + (extras.length ? " · " + extras.join("/") : "")
@@ -15,13 +15,11 @@ import { acquireTaskTracker } from '../../src/task-track.js'
15
15
  export const feature = {
16
16
  id: 'runstate',
17
17
  name: '运行状态',
18
- description: '任务运行状态一览:进行中任务(阶段 / 耗时 / 回合 / Token / 等待确认)与最近完成',
18
+ description: '任务运行状态一览:进行中任务(阶段 / 耗时 / 回合 / Token / 等人介入)与最近完成',
19
19
  defaultEnabled: false,
20
20
  setup(ctx) {
21
21
  const disposers = []
22
22
  const dispose = () => {
23
- // 撤掉「本宿主注册了 runstate 路由」旗标(见下方注册处的说明)
24
- try { if (typeof window !== 'undefined' && window.__dshDockRunstate) delete window.__dshDockRunstate } catch { /* ignore */ }
25
23
  while (disposers.length > 0) {
26
24
  const fn = disposers.pop()
27
25
  try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
@@ -35,14 +33,6 @@ export const feature = {
35
33
 
36
34
  // ===== RPC 路由 =====
37
35
  disposers.push(ctx.inject(['webServer'], (wsCtx) => {
38
- // 路由注册成功后打旗标:【手机适配】的汉堡角标据此决定要不要轮询 status。
39
- // 没有它的话,功能停用时角标会先打一次必然失败的请求 → 控制台一条 405/404,
40
- // 与【用量记录】chip 当年同一个毛病(见 features/tokenlog/view.jsx 的 routeGone 闩锁)。
41
- // 旗标与 __dshDockMobileDrawer / __dshDockWelcomeAck 同风格:宿主侧唯一事实,客户端只读。
42
- wsCtx.effect(() => {
43
- if (typeof window !== 'undefined') window.__dshDockRunstate = true
44
- return () => { try { if (typeof window !== 'undefined' && window.__dshDockRunstate) delete window.__dshDockRunstate } catch { /* ignore */ } }
45
- })
46
36
  wsCtx.effect(() => wsCtx.webServer.register({
47
37
  kind: 'prefix',
48
38
  path: '/dsh-dock/runstate',