dsh-perm-gate 2.1.2 → 4.0.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.
Files changed (57) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +225 -0
  4. package/INSTALL.ja.md +15 -9
  5. package/INSTALL.ko.md +16 -10
  6. package/INSTALL.md +17 -9
  7. package/INSTALL.zh.md +13 -8
  8. package/README.ja.md +17 -5
  9. package/README.ko.md +18 -6
  10. package/README.md +33 -3
  11. package/README.zh.md +11 -3
  12. package/docs/baseline-guard.md +56 -0
  13. package/docs/rules-format.en.md +294 -0
  14. package/docs/rules-format.md +279 -0
  15. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  16. package/dsh.plugin.json +2 -2
  17. package/examples/permissions.example.yaml +16 -1
  18. package/lib/allowlist.d.ts +27 -0
  19. package/lib/allowlist.js +103 -1
  20. package/lib/cli.js +11 -14
  21. package/lib/client.js +646 -95
  22. package/lib/client.js.map +1 -1
  23. package/lib/command-dispatcher.d.ts +22 -0
  24. package/lib/command-dispatcher.js +97 -0
  25. package/lib/command-semantics.d.ts +62 -0
  26. package/lib/command-semantics.js +6 -0
  27. package/lib/compiler.d.ts +11 -0
  28. package/lib/compiler.js +16 -0
  29. package/lib/config.d.ts +73 -12
  30. package/lib/config.js +56 -14
  31. package/lib/dry-run.d.ts +87 -0
  32. package/lib/dry-run.js +110 -0
  33. package/lib/engine.js +52 -2
  34. package/lib/evaluate.js +76 -2
  35. package/lib/events.d.ts +91 -2
  36. package/lib/events.js +220 -0
  37. package/lib/index.js +111 -26
  38. package/lib/parsers/git.d.ts +43 -0
  39. package/lib/parsers/git.js +404 -0
  40. package/lib/parsers/shell-cmds.d.ts +32 -0
  41. package/lib/parsers/shell-cmds.js +293 -0
  42. package/lib/preset.d.ts +13 -0
  43. package/lib/preset.js +17 -0
  44. package/lib/risk.d.ts +17 -3
  45. package/lib/risk.js +17 -3
  46. package/lib/rule-chain.js +13 -22
  47. package/lib/rule-dims.d.ts +35 -0
  48. package/lib/rule-dims.js +36 -0
  49. package/lib/rule.d.ts +36 -1
  50. package/lib/rule.js +56 -8
  51. package/lib/rules-view.d.ts +52 -0
  52. package/lib/rules-view.js +126 -0
  53. package/lib/runtime.d.ts +121 -7
  54. package/lib/runtime.js +204 -25
  55. package/lib/shell.d.ts +14 -0
  56. package/lib/shell.js +17 -0
  57. package/package.json +13 -5
package/lib/client.js CHANGED
@@ -16,10 +16,11 @@ window.__ModuleLoader__.load({
16
16
  "card.description": "独立审批档位:与只读 / 完全权限 / 白名单平行,在会话权限下拉中显示为「自动审查」。前端只暴露一个开关;后台四个审批策略可组合、由此处设置决定,仍对 P0 硬拒绝保持 fail-closed。",
17
17
  "card.permissive": "启用自动审查档位",
18
18
  "card.permissiveHint": "关闭时门禁行为与此前完全一致。",
19
+ "card.scopeNote": "档位作用域:本门禁仅在会话权限档位为 %scope 时生效。其他档位下整个门禁停用(含 P0 硬拒绝),由所选档位自身的策略接管;停用时会写入一条 stand-down 事件,并在输入框上方常驻一条提示条。",
19
20
  "card.strategies": "后台审批策略(至少一个生效;可组合)",
20
21
  "card.strategy.trustAutoAllow": "trustAutoAllow — 作用域内安全操作自动放行,危险/未知转 ask(中间档基线)",
21
22
  "card.strategy.alwaysConfirm": "alwaysConfirm — 一律逐次 ask;审批面板「允许控件」含两个扩展按钮:「本会话重复允许该类」(会话语限次 grant)与「允许所有类型」(命令词持久迁入 rulesFile 白名单)",
22
- "card.strategy.llmAssist": "llmAssist — 自定义 LLM 按风险分级裁决:safe 自动放行;deletion/credential/remote/system/bulk 硬风险自动拒绝;neutral 进入确认学习;失败/超时回退人工",
23
+ "card.strategy.llmAssist": "llmAssist — 自定义 LLM 按风险分级裁决:safe 自动放行;deletion/credential/remote/system/bulk 硬风险维持人工确认(分类器永不拒绝,只负责升级判断);neutral 进入确认学习;失败/超时回退人工",
23
24
  "card.strategy.trustEscalation": "trustEscalation — 沙箱升级免确认:本门禁已放行的调用,其工具内部发起的沙箱提权(shell / pwsh / edit 的 sandbox_permissions)由门禁直接批准,不再弹窗",
24
25
  "card.strategy.trustEscalationHint": "沙箱提权审批发生在 tools/pre-execute 之后、工具体内部,门禁原本看不到它,因此「LLM 判安全已自动放行」的调用仍会弹窗要求你批准提权。开启后,仅对门禁确实放行过的那一次调用(按 callId 精确匹配)免确认,其余一律照旧走人工;目标模式非 workspace-write / danger-full-access 或理由无法识别时同样回退人工。关闭后提权保持人工把关。",
25
26
  "card.readonly": "只读",
@@ -88,8 +89,6 @@ window.__ModuleLoader__.load({
88
89
  "card.llmPresetHost": "DSH 模型组(选择后自动切换为会话模式)",
89
90
  "card.networkEnabled": "网络拦截(networkEnabled)",
90
91
  "card.networkEnabledHint": "开启后启动本地代理拦截子进程出站连接,按规则放行/阻断。关闭则零行为变更。",
91
- "card.watch": "规则热重载(watch)",
92
- "card.watchHint": "开启后自动监听规则文件变更并即时生效,无需手动 reload。",
93
92
  "card.networkRebindNote": "切换后立即生效(自动重绑,无需重载插件)。",
94
93
  "card.networkInjectEnv": "注入代理环境变量(networkInjectEnv)",
95
94
  "card.networkInjectEnvHint": "开启后为子进程写入 HTTP(S)_PROXY / ALL_PROXY(并清空 NO_PROXY)。关闭则只启动监听、不改变任何进程环境。",
@@ -102,6 +101,7 @@ window.__ModuleLoader__.load({
102
101
  "history.tag.manualApproved": "人工通过",
103
102
  "history.tag.manualRejected": "人工拒绝",
104
103
  "history.tag.manualCancelled": "人工取消",
104
+ "history.tag.standDown": "门禁停用",
105
105
  "history.learnedProgress": "学习 %n/%t",
106
106
  "history.snapshots": "diff 快照",
107
107
  "history.clearSession": "仅清本会话",
@@ -124,7 +124,41 @@ window.__ModuleLoader__.load({
124
124
  "history.diffChipTitle": "查看该文件改动对比",
125
125
  "history.diffStats": "+%a / -%r 行变更 · %c 行未变",
126
126
  "history.diffGone": "(文件当前已不存在)",
127
- "history.diffHidden": "%n 行未修改"
127
+ "history.diffHidden": "%n 行未修改",
128
+ "card.dryRun": "规则测试",
129
+ "card.dryRunHint": "用当前生效的规则判定一次调用,不执行任何工具、不写入任何规则。改规则前先在这里验一遍。",
130
+ "card.dryRunTool": "工具名",
131
+ "card.dryRunCommand": "命令 / 参数",
132
+ "card.dryRunCommandPlaceholder": "git push --force origin main",
133
+ "card.dryRunTest": "测试",
134
+ "card.dryRunRunning": "判定中…",
135
+ "card.dryRunEmpty": "尚未测试。填入工具名与命令,点「测试」查看会怎么裁决。",
136
+ "card.dryRunVerdict": "裁决",
137
+ "card.dryRunAllow": "放行",
138
+ "card.dryRunAsk": "人工确认",
139
+ "card.dryRunDeny": "拒绝",
140
+ "card.dryRunRule": "命中规则",
141
+ "card.dryRunRuleNone": "无规则命中,走 defaultAction:%d",
142
+ "card.dryRunDimensions": "约束维度",
143
+ "card.dryRunReason": "原因",
144
+ "card.dryRunNote": "「命中规则」来自规则层。若裁决由更早的硬拒绝或关键词黑名单产生,则不会有规则序号——那种情况下列出的维度仅供参考。",
145
+ "card.dryRunNoRules": "(当前加载了 0 条规则:检查 rulesFile 路径)",
146
+ "card.dryRunFail": "测试失败:",
147
+ "card.dryRunStale": "路由不存在(插件未重启?)",
148
+ "card.rules": "权限 YAML",
149
+ "card.rulesHint": "门禁此刻实际加载的规则文件原文。只读——这里改不了任何东西。要改规则,用下面的白名单,或直接编辑该文件。",
150
+ "card.rulesRefresh": "刷新",
151
+ "card.rulesLoading": "读取中…",
152
+ "card.rulesPath": "文件",
153
+ "card.rulesNone": "未配置规则文件",
154
+ "card.rulesCounts": "规则",
155
+ "card.rulesDefault": "defaultAction",
156
+ "card.rulesStats": "%b 字节 · %l 行",
157
+ "card.rulesMissing": "文件不存在。门禁会退到内置默认。",
158
+ "card.rulesTruncated": "文件过大,仅显示前 512 KB。",
159
+ "card.rulesError": "加载失败:",
160
+ "card.rulesStale": "路由不存在(插件未重启?)",
161
+ "card.rulesNote": "此处只读:面板无法修改该文件,白名单段是唯一会写入它的地方。"
128
162
  };
129
163
  /** English dictionary (keys mirror zh). */
130
164
  const en = {
@@ -132,10 +166,11 @@ window.__ModuleLoader__.load({
132
166
  "card.description": "Independent approval tier, parallel to read-only / full-access / whitelist, shown as 自动审查 in the session permission picker. The front-end exposes a single switch; the four backend approval strategies are combinable via this panel and still fail-closed against P0 hard-deny.",
133
167
  "card.permissive": "Enable the 自动审查 tier",
134
168
  "card.permissiveHint": "When off, the gate behaves exactly as before.",
169
+ "card.scopeNote": "Preset scope: this gate only acts while the session permission preset is %scope. Under any other preset the whole gate stands down — P0 hard-deny included — and the selected preset's own policy takes over. A stand-down records one event and leaves a sticky strip above the input.",
135
170
  "card.strategies": "Backend approval strategies (at least one effective; combinable)",
136
171
  "card.strategy.trustAutoAllow": "trustAutoAllow — safe in-scope ops auto-allow; dangerous/unknown ask (baseline middle tier)",
137
172
  "card.strategy.alwaysConfirm": "alwaysConfirm — every crossing asks; the approval \"allow controls\" get two extended buttons: \"repeat-allow this type this session\" (bounded session grant) and \"allow every occurrence\" (persist the command word into the rulesFile whitelist)",
138
- "card.strategy.llmAssist": "llmAssist — a custom LLM grades risk: safe auto-allows; deletion/credential/remote/system/bulk hard risks auto-deny; neutral enters confirm-learning; failure/timeout falls back to the human",
173
+ "card.strategy.llmAssist": "llmAssist — a custom LLM grades risk: safe auto-allows; deletion/credential/remote/system/bulk hard risks keep the human ask (the classifier never denies — it only escalates); neutral enters confirm-learning; failure/timeout falls back to the human",
139
174
  "card.strategy.trustEscalation": "trustEscalation — sandbox escalation without a prompt: for a call this gate already allowed, the sandbox widening a shell / pwsh / edit tool raises from inside itself is approved here instead of asking you",
140
175
  "card.strategy.trustEscalationHint": "A sandbox escalation is asked from inside the tool body, after tools/pre-execute, so the gate never saw it — a call the LLM graded safe and the gate auto-allowed still prompted you to approve the widening. With this on, only the exact call the gate allowed (matched by callId) skips the prompt; everything else goes to the human as before, as does any unrecognized reason or a target other than workspace-write / danger-full-access. Turn it off to keep widening human-gated.",
141
176
  "card.readonly": "Read-only",
@@ -204,8 +239,6 @@ window.__ModuleLoader__.load({
204
239
  "card.llmPresetHost": "DSH model groups (selecting one switches to the session receiver)",
205
240
  "card.networkEnabled": "Network interception (networkEnabled)",
206
241
  "card.networkEnabledHint": "When on, starts a local proxy that intercepts outbound subprocess connections and applies allow/deny rules. Off = zero behavior change.",
207
- "card.watch": "Rule hot-reload (watch)",
208
- "card.watchHint": "When on, automatically watches rule files for changes and applies them instantly without manual reload.",
209
242
  "card.networkRebindNote": "Takes effect immediately — the proxy rebinds automatically, no plugin reload needed.",
210
243
  "card.networkInjectEnv": "Inject proxy environment (networkInjectEnv)",
211
244
  "card.networkInjectEnvHint": "Writes HTTP(S)_PROXY / ALL_PROXY for subprocesses (and clears NO_PROXY). Off = the listener runs but no process environment is changed.",
@@ -218,6 +251,7 @@ window.__ModuleLoader__.load({
218
251
  "history.tag.manualApproved": "Approved",
219
252
  "history.tag.manualRejected": "Rejected",
220
253
  "history.tag.manualCancelled": "Cancelled",
254
+ "history.tag.standDown": "Gate off",
221
255
  "history.learnedProgress": "learned %n/%t",
222
256
  "history.snapshots": "diff snapshots",
223
257
  "history.clearSession": "Clear this session",
@@ -240,7 +274,41 @@ window.__ModuleLoader__.load({
240
274
  "history.diffChipTitle": "View this file’s change comparison",
241
275
  "history.diffStats": "+%a / -%r lines changed · %c unchanged",
242
276
  "history.diffGone": " (file no longer exists)",
243
- "history.diffHidden": "%n unmodified lines"
277
+ "history.diffHidden": "%n unmodified lines",
278
+ "card.dryRun": "Rule test",
279
+ "card.dryRunHint": "Judge one call against the rules in force. Runs no tool and writes no rule — check a rule here before you change it.",
280
+ "card.dryRunTool": "Tool",
281
+ "card.dryRunCommand": "Command / arguments",
282
+ "card.dryRunCommandPlaceholder": "git push --force origin main",
283
+ "card.dryRunTest": "Test",
284
+ "card.dryRunRunning": "Judging…",
285
+ "card.dryRunEmpty": "Nothing tested yet. Fill in a tool and command, then press Test to see how it would be judged.",
286
+ "card.dryRunVerdict": "Verdict",
287
+ "card.dryRunAllow": "allow",
288
+ "card.dryRunAsk": "ask",
289
+ "card.dryRunDeny": "deny",
290
+ "card.dryRunRule": "Matched rule",
291
+ "card.dryRunRuleNone": "no rule matched; defaultAction: %d",
292
+ "card.dryRunDimensions": "Dimensions",
293
+ "card.dryRunReason": "Reason",
294
+ "card.dryRunNote": "\"Matched rule\" comes from the rule layer. When the verdict was produced by an earlier hard-deny or by the keyword blacklist there is no rule index — the dimensions listed are then informational only.",
295
+ "card.dryRunNoRules": "(0 rules loaded — check the rulesFile path)",
296
+ "card.dryRunFail": "Test failed: ",
297
+ "card.dryRunStale": "route missing (plugin not restarted?)",
298
+ "card.rules": "Permissions YAML",
299
+ "card.rulesHint": "The rules file the gate is loading right now, verbatim. Read-only — nothing here can change it. To change rules, use the allowlist below or edit the file directly.",
300
+ "card.rulesRefresh": "Refresh",
301
+ "card.rulesLoading": "Reading…",
302
+ "card.rulesPath": "File",
303
+ "card.rulesNone": "no rules file configured",
304
+ "card.rulesCounts": "Rules",
305
+ "card.rulesDefault": "defaultAction",
306
+ "card.rulesStats": "%b bytes · %l lines",
307
+ "card.rulesMissing": "The file does not exist. The gate falls back to its built-in defaults.",
308
+ "card.rulesTruncated": "File is large; showing the first 512 KB only.",
309
+ "card.rulesError": "Load failed: ",
310
+ "card.rulesStale": "route missing (plugin not restarted?)",
311
+ "card.rulesNote": "Read-only: this panel cannot modify the file. The allowlist section is the only place that writes to it."
244
312
  };
245
313
  //#endregion
246
314
  //#region src/deny-defaults.ts
@@ -309,6 +377,19 @@ window.__ModuleLoader__.load({
309
377
  }
310
378
  ];
311
379
  //#endregion
380
+ //#region src/preset.ts
381
+ /**
382
+ * Presets in which the gate is active. It owns both 自动审查 tiers — the plain
383
+ * one (file sandbox kept) and the full-access one (sandbox restriction lifted,
384
+ * same approval behaviour) — so a user can have the gate without inheriting a
385
+ * sandbox that breaks `git` / Cygwin tools. `'*'` makes it global (every
386
+ * preset, including the hard-deny layer).
387
+ *
388
+ * Lives in this dependency-free module (not `config.ts`) so the browser half can
389
+ * render the scope without pulling schemastery into the client bundle.
390
+ */
391
+ const DEFAULT_GATE_PRESETS = ["permissive", "permissive-full"];
392
+ //#endregion
312
393
  //#region src/client/sediment.tsx
313
394
  /**
314
395
  * Learning-sediment management section — the settings-card face of verdict
@@ -320,7 +401,6 @@ window.__ModuleLoader__.load({
320
401
  * learning.json store the gate reads); actions POST back. Best-effort: a
321
402
  * missing route renders the empty state, never an error.
322
403
  */
323
- const POLL_MS$2 = 5e3;
324
404
  const itemStyle = {
325
405
  display: "flex",
326
406
  alignItems: "center",
@@ -350,6 +430,8 @@ window.__ModuleLoader__.load({
350
430
  };
351
431
  function SedimentSection({ t }) {
352
432
  const [body, setBody] = (0, react.useState)(null);
433
+ /** Latest loader, so a mutation can refresh the list without owning the fetch. */
434
+ const reloadRef = (0, react.useRef)(() => {});
353
435
  (0, react.useEffect)(() => {
354
436
  let alive = true;
355
437
  const load = () => {
@@ -359,11 +441,18 @@ window.__ModuleLoader__.load({
359
441
  if (alive) setBody({});
360
442
  });
361
443
  };
444
+ reloadRef.current = load;
362
445
  load();
363
- const timer = setInterval(load, POLL_MS$2);
446
+ const onVisible = () => {
447
+ if (document.visibilityState === "visible") load();
448
+ };
449
+ window.addEventListener("focus", onVisible);
450
+ document.addEventListener("visibilitychange", onVisible);
364
451
  return () => {
365
452
  alive = false;
366
- clearInterval(timer);
453
+ reloadRef.current = () => {};
454
+ window.removeEventListener("focus", onVisible);
455
+ document.removeEventListener("visibilitychange", onVisible);
367
456
  };
368
457
  }, []);
369
458
  const post = (payload) => {
@@ -371,6 +460,8 @@ window.__ModuleLoader__.load({
371
460
  method: "POST",
372
461
  headers: { "content-type": "application/json" },
373
462
  body: JSON.stringify(payload)
463
+ }).then(() => {
464
+ reloadRef.current();
374
465
  }).catch(() => {});
375
466
  };
376
467
  const threshold = body?.threshold ?? 3;
@@ -559,6 +650,67 @@ window.__ModuleLoader__.load({
559
650
  const [bulkOpen, setBulkOpen] = (0, react.useState)(false);
560
651
  const [healthBusy, setHealthBusy] = (0, react.useState)(false);
561
652
  const [healthResult, setHealthResult] = (0, react.useState)(null);
653
+ const [dryTool, setDryTool] = (0, react.useState)("shell");
654
+ const [dryCommand, setDryCommand] = (0, react.useState)("");
655
+ const [dryBusy, setDryBusy] = (0, react.useState)(false);
656
+ const [dryReport, setDryReport] = (0, react.useState)(null);
657
+ const [dryError, setDryError] = (0, react.useState)(null);
658
+ const runDryRunTest = () => {
659
+ const tool = dryTool.trim() === "" ? "shell" : dryTool.trim();
660
+ const command = dryCommand.trim();
661
+ setDryBusy(true);
662
+ setDryError(null);
663
+ setDryReport(null);
664
+ fetch("/api/dsh-perm-gate/dry-run", {
665
+ method: "POST",
666
+ headers: { "content-type": "application/json" },
667
+ body: JSON.stringify({
668
+ tool,
669
+ args: command === "" ? {} : { command }
670
+ })
671
+ }).then(async (r) => {
672
+ const text = await r.text();
673
+ if (r.status === 404) throw new Error(t("card.dryRunStale"));
674
+ try {
675
+ return JSON.parse(text);
676
+ } catch {
677
+ throw new Error(text.trim().slice(0, 120) !== "" ? text.trim().slice(0, 120) : `HTTP ${r.status}`);
678
+ }
679
+ }).then((res) => {
680
+ if (res.ok === true && res.result !== void 0) setDryReport(res.result);
681
+ else setDryError(res.error ?? t("card.dryRunFail"));
682
+ }).catch((e) => {
683
+ setDryError(String(e?.message ?? e));
684
+ }).finally(() => {
685
+ setDryBusy(false);
686
+ });
687
+ };
688
+ const [rulesBody, setRulesBody] = (0, react.useState)(null);
689
+ const [rulesBusy, setRulesBusy] = (0, react.useState)(false);
690
+ const [rulesError, setRulesError] = (0, react.useState)(null);
691
+ const loadRules = () => {
692
+ setRulesBusy(true);
693
+ setRulesError(null);
694
+ fetch("/api/dsh-perm-gate/rules", { headers: { "cache-control": "no-cache" } }).then(async (r) => {
695
+ const text = await r.text();
696
+ if (r.status === 404) throw new Error(t("card.rulesStale"));
697
+ try {
698
+ return JSON.parse(text);
699
+ } catch {
700
+ throw new Error(text.trim().slice(0, 120) !== "" ? text.trim().slice(0, 120) : `HTTP ${r.status}`);
701
+ }
702
+ }).then((res) => {
703
+ if (res.ok === true && res.result !== void 0) setRulesBody(res.result);
704
+ else setRulesError(res.error ?? t("card.rulesError"));
705
+ }).catch((e) => {
706
+ setRulesError(String(e?.message ?? e));
707
+ }).finally(() => {
708
+ setRulesBusy(false);
709
+ });
710
+ };
711
+ (0, react.useEffect)(() => {
712
+ loadRules();
713
+ }, []);
562
714
  const receiverSource = (value.classifierSource ?? "custom") === "host" ? "host" : "custom";
563
715
  const [receiverInfo, setReceiverInfo] = (0, react.useState)(null);
564
716
  const [receiverNonce, setReceiverNonce] = (0, react.useState)(0);
@@ -696,6 +848,10 @@ window.__ModuleLoader__.load({
696
848
  style: hintStyle,
697
849
  children: t("card.permissiveHint")
698
850
  }),
851
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
852
+ style: hintStyle,
853
+ children: t("card.scopeNote").replace("%scope", ((value.gatePresets ?? []).length > 0 ? value.gatePresets : [...DEFAULT_GATE_PRESETS]).join(" / "))
854
+ }),
699
855
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
700
856
  style: sectionStyle,
701
857
  children: [
@@ -1115,6 +1271,325 @@ window.__ModuleLoader__.load({
1115
1271
  }) : null
1116
1272
  ]
1117
1273
  }),
1274
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1275
+ style: sectionStyle,
1276
+ children: [
1277
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1278
+ style: labelStyle,
1279
+ children: t("card.dryRun")
1280
+ }),
1281
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1282
+ style: hintStyle,
1283
+ children: t("card.dryRunHint")
1284
+ }),
1285
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1286
+ style: {
1287
+ display: "flex",
1288
+ flexWrap: "wrap",
1289
+ gap: "8px",
1290
+ alignItems: "flex-end",
1291
+ margin: "6px 0"
1292
+ },
1293
+ children: [
1294
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1295
+ style: {
1296
+ display: "flex",
1297
+ flexDirection: "column",
1298
+ gap: "4px",
1299
+ flex: "0 0 120px"
1300
+ },
1301
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1302
+ style: fieldLabelStyle,
1303
+ children: t("card.dryRunTool")
1304
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1305
+ type: "text",
1306
+ value: dryTool,
1307
+ disabled: readonly,
1308
+ placeholder: "shell",
1309
+ onChange: (e) => {
1310
+ setDryTool(e.target.value);
1311
+ },
1312
+ onKeyDown: (e) => {
1313
+ if (e.key === "Enter") runDryRunTest();
1314
+ },
1315
+ style: controlStyle
1316
+ })]
1317
+ }),
1318
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1319
+ style: {
1320
+ display: "flex",
1321
+ flexDirection: "column",
1322
+ gap: "4px",
1323
+ flex: "1 1 260px",
1324
+ minWidth: 0
1325
+ },
1326
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1327
+ style: fieldLabelStyle,
1328
+ children: t("card.dryRunCommand")
1329
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1330
+ type: "text",
1331
+ value: dryCommand,
1332
+ disabled: readonly,
1333
+ placeholder: t("card.dryRunCommandPlaceholder"),
1334
+ onChange: (e) => {
1335
+ setDryCommand(e.target.value);
1336
+ },
1337
+ onKeyDown: (e) => {
1338
+ if (e.key === "Enter") runDryRunTest();
1339
+ },
1340
+ style: controlStyle
1341
+ })]
1342
+ }),
1343
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1344
+ type: "button",
1345
+ disabled: readonly || dryBusy,
1346
+ onClick: runDryRunTest,
1347
+ style: {
1348
+ ...controlStyle,
1349
+ flex: "0 0 auto",
1350
+ cursor: readonly || dryBusy ? "default" : "pointer"
1351
+ },
1352
+ children: dryBusy ? t("card.dryRunRunning") : t("card.dryRunTest")
1353
+ })
1354
+ ]
1355
+ }),
1356
+ dryError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1357
+ style: {
1358
+ ...hintStyle,
1359
+ color: "var(--dsw-alias-label-error, #c0392b)"
1360
+ },
1361
+ children: [t("card.dryRunFail"), dryError]
1362
+ }),
1363
+ dryReport === null ? dryError === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1364
+ style: hintStyle,
1365
+ children: t("card.dryRunEmpty")
1366
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1367
+ style: {
1368
+ display: "flex",
1369
+ flexDirection: "column",
1370
+ gap: "4px",
1371
+ padding: "8px 10px",
1372
+ borderRadius: "6px",
1373
+ border: "1px solid var(--dsw-alias-border-l2)",
1374
+ background: "var(--dsw-alias-bg-surface, transparent)",
1375
+ fontSize: "12px",
1376
+ lineHeight: "18px"
1377
+ },
1378
+ children: [
1379
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1380
+ style: {
1381
+ display: "flex",
1382
+ alignItems: "center",
1383
+ gap: "8px"
1384
+ },
1385
+ children: [
1386
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1387
+ style: fieldLabelStyle,
1388
+ children: t("card.dryRunVerdict")
1389
+ }),
1390
+ (() => {
1391
+ const verdict = dryReport.verdict ?? "ask";
1392
+ const colour = verdict === "deny" ? "var(--dsw-alias-label-error, #c0392b)" : verdict === "allow" ? "var(--dsw-alias-label-success, #2e7d32)" : "var(--dsw-alias-label-warning, #b26a00)";
1393
+ const text = verdict === "deny" ? t("card.dryRunDeny") : verdict === "allow" ? t("card.dryRunAllow") : t("card.dryRunAsk");
1394
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
1395
+ style: { color: colour },
1396
+ children: text
1397
+ });
1398
+ })(),
1399
+ (dryReport.ruleCount ?? 0) === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1400
+ style: { color: "var(--dsw-alias-label-secondary, inherit)" },
1401
+ children: t("card.dryRunNoRules")
1402
+ })
1403
+ ]
1404
+ }),
1405
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1406
+ style: {
1407
+ display: "flex",
1408
+ gap: "8px",
1409
+ overflowWrap: "anywhere"
1410
+ },
1411
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1412
+ style: fieldLabelStyle,
1413
+ children: t("card.dryRunRule")
1414
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1415
+ style: {
1416
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
1417
+ color: "var(--dsw-alias-label-primary)"
1418
+ },
1419
+ children: dryReport.ruleLayer?.ruleIndex === void 0 ? t("card.dryRunRuleNone").replace("%d", dryReport.defaultAction ?? "ask") : `#${dryReport.ruleLayer.ruleIndex} (${dryReport.ruleLayer.action ?? "?"})`
1420
+ })]
1421
+ }),
1422
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1423
+ style: {
1424
+ display: "flex",
1425
+ gap: "8px",
1426
+ overflowWrap: "anywhere"
1427
+ },
1428
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1429
+ style: fieldLabelStyle,
1430
+ children: t("card.dryRunDimensions")
1431
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1432
+ style: {
1433
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
1434
+ color: "var(--dsw-alias-label-primary)"
1435
+ },
1436
+ children: (dryReport.ruleLayer?.matchedDimensions ?? []).length === 0 ? "—" : (dryReport.ruleLayer?.matchedDimensions ?? []).join(", ")
1437
+ })]
1438
+ }),
1439
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1440
+ style: {
1441
+ display: "flex",
1442
+ gap: "8px",
1443
+ overflowWrap: "anywhere"
1444
+ },
1445
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1446
+ style: fieldLabelStyle,
1447
+ children: t("card.dryRunReason")
1448
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1449
+ style: { color: "var(--dsw-alias-label-secondary, inherit)" },
1450
+ children: dryReport.reason ?? ""
1451
+ })]
1452
+ }),
1453
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1454
+ style: {
1455
+ ...hintStyle,
1456
+ margin: 0
1457
+ },
1458
+ children: t("card.dryRunNote")
1459
+ })
1460
+ ]
1461
+ })
1462
+ ]
1463
+ }),
1464
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1465
+ style: sectionStyle,
1466
+ children: [
1467
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1468
+ style: {
1469
+ display: "flex",
1470
+ alignItems: "center",
1471
+ justifyContent: "space-between",
1472
+ gap: "8px"
1473
+ },
1474
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1475
+ style: labelStyle,
1476
+ children: t("card.rules")
1477
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1478
+ type: "button",
1479
+ disabled: rulesBusy,
1480
+ onClick: loadRules,
1481
+ style: {
1482
+ ...controlStyle,
1483
+ flex: "0 0 auto",
1484
+ cursor: rulesBusy ? "default" : "pointer"
1485
+ },
1486
+ children: rulesBusy ? t("card.rulesLoading") : t("card.rulesRefresh")
1487
+ })]
1488
+ }),
1489
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1490
+ style: hintStyle,
1491
+ children: t("card.rulesHint")
1492
+ }),
1493
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1494
+ style: {
1495
+ display: "flex",
1496
+ gap: "8px",
1497
+ overflowWrap: "anywhere",
1498
+ marginTop: "6px"
1499
+ },
1500
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1501
+ style: fieldLabelStyle,
1502
+ children: t("card.rulesPath")
1503
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1504
+ style: {
1505
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
1506
+ color: "var(--dsw-alias-label-primary)"
1507
+ },
1508
+ children: rulesBody?.path !== void 0 && rulesBody.path !== "" ? rulesBody.path : t("card.rulesNone")
1509
+ })]
1510
+ }),
1511
+ rulesError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1512
+ style: {
1513
+ ...hintStyle,
1514
+ color: "var(--dsw-alias-label-error, #c0392b)"
1515
+ },
1516
+ children: [t("card.rulesError"), rulesError]
1517
+ }),
1518
+ rulesBody !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1519
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1520
+ style: {
1521
+ display: "flex",
1522
+ flexWrap: "wrap",
1523
+ gap: "12px",
1524
+ marginTop: "4px"
1525
+ },
1526
+ children: [
1527
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1528
+ style: fieldLabelStyle,
1529
+ children: [t("card.rulesCounts"), " "]
1530
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1531
+ style: {
1532
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
1533
+ color: "var(--dsw-alias-label-primary)"
1534
+ },
1535
+ children: `deny ${rulesBody.counts?.deny ?? 0} · allow ${rulesBody.counts?.allow ?? 0} · ask ${rulesBody.counts?.ask ?? 0}`
1536
+ })] }),
1537
+ rulesBody.defaultAction !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1538
+ style: fieldLabelStyle,
1539
+ children: [t("card.rulesDefault"), " "]
1540
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
1541
+ style: {
1542
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
1543
+ color: "var(--dsw-alias-label-primary)"
1544
+ },
1545
+ children: rulesBody.defaultAction
1546
+ })] }),
1547
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1548
+ style: fieldLabelStyle,
1549
+ children: t("card.rulesStats").replace("%b", String(rulesBody.bytes ?? 0)).replace("%l", String(rulesBody.lines ?? 0))
1550
+ })
1551
+ ]
1552
+ }),
1553
+ rulesBody.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1554
+ style: {
1555
+ ...hintStyle,
1556
+ color: "var(--dsw-alias-label-error, #c0392b)"
1557
+ },
1558
+ children: rulesBody.error
1559
+ }),
1560
+ rulesBody.exists === false && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1561
+ style: hintStyle,
1562
+ children: t("card.rulesMissing")
1563
+ }),
1564
+ rulesBody.truncated === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1565
+ style: hintStyle,
1566
+ children: t("card.rulesTruncated")
1567
+ }),
1568
+ (rulesBody.raw ?? "") !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
1569
+ style: {
1570
+ margin: "6px 0 0",
1571
+ padding: "8px 10px",
1572
+ maxHeight: "260px",
1573
+ overflow: "auto",
1574
+ borderRadius: "6px",
1575
+ border: "1px solid var(--dsw-alias-border-l2)",
1576
+ background: "var(--dsw-alias-bg-surface, transparent)",
1577
+ font: "500 12px/18px var(--ds-font-family-code, monospace)",
1578
+ color: "var(--dsw-alias-label-primary)",
1579
+ whiteSpace: "pre"
1580
+ },
1581
+ children: rulesBody.raw
1582
+ }),
1583
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1584
+ style: {
1585
+ ...hintStyle,
1586
+ margin: 0
1587
+ },
1588
+ children: t("card.rulesNote")
1589
+ })
1590
+ ] })
1591
+ ]
1592
+ }),
1118
1593
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1119
1594
  style: sectionStyle,
1120
1595
  children: [
@@ -1454,7 +1929,7 @@ window.__ModuleLoader__.load({
1454
1929
  })
1455
1930
  ]
1456
1931
  }),
1457
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1932
+ value.networkEnabled === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1458
1933
  style: sectionStyle,
1459
1934
  children: [
1460
1935
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -1485,39 +1960,7 @@ window.__ModuleLoader__.load({
1485
1960
  })]
1486
1961
  })
1487
1962
  ]
1488
- }),
1489
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1490
- style: sectionStyle,
1491
- children: [
1492
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1493
- style: labelStyle,
1494
- children: t("card.watch")
1495
- }),
1496
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1497
- style: hintStyle,
1498
- children: t("card.watchHint")
1499
- }),
1500
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1501
- style: {
1502
- display: "flex",
1503
- alignItems: "center",
1504
- gap: "8px",
1505
- marginTop: "6px"
1506
- },
1507
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1508
- type: "checkbox",
1509
- checked: value.watch !== false,
1510
- disabled: readonly,
1511
- onChange: (event) => {
1512
- scope.set("watch", event.currentTarget.checked);
1513
- }
1514
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1515
- style: { fontSize: "13px" },
1516
- children: value.watch !== false ? "ON" : "OFF"
1517
- })]
1518
- })
1519
- ]
1520
- }),
1963
+ }) : null,
1521
1964
  !snapshot.writable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1522
1965
  style: {
1523
1966
  margin: "8px 0 0",
@@ -1540,6 +1983,95 @@ window.__ModuleLoader__.load({
1540
1983
  const body = await res.json();
1541
1984
  return Array.isArray(body.events) ? body.events : [];
1542
1985
  }
1986
+ /** The host's push route (see src/events.ts); the browser half prefers it to polling. */
1987
+ const STREAM_ROUTE = "/api/dsh-perm-gate/stream";
1988
+ /** Stream attempts before the route is written off as unsupported and polling takes over. */
1989
+ const STREAM_RETRIES = 3;
1990
+ const STREAM_RETRY_MS = 3e3;
1991
+ /**
1992
+ * Degraded path only: a host predating the stream route still gets a servable
1993
+ * strip. Deliberately slower than the old 2 s tick — it now also pauses while
1994
+ * the tab is hidden, so a backgrounded page costs nothing.
1995
+ */
1996
+ const FALLBACK_POLL_MS = 5e3;
1997
+ /**
1998
+ * Follow one session's decision feed until the returned close is called.
1999
+ *
2000
+ * Prefers the host's `text/event-stream` route, which pushes each decision as
2001
+ * the gate appends it — no timer, no repeated re-read of the event log. When
2002
+ * the stream is unavailable (an older host, a proxy that buffers it) this
2003
+ * degrades to polling the JSON route, gated on page visibility.
2004
+ *
2005
+ * @param sessionId - the session whose decisions to follow.
2006
+ * @param getSince - cursor supplier, read at connect and on every fallback poll.
2007
+ * @param onBatch - receives the backlog, then live batches.
2008
+ * @returns the close: ends the stream and any fallback timer.
2009
+ */
2010
+ function followEvents(sessionId, getSince, onBatch) {
2011
+ let closed = false;
2012
+ let source = null;
2013
+ let pollTimer = null;
2014
+ let retries = 0;
2015
+ let polls = 0;
2016
+ const stopPolling = () => {
2017
+ if (pollTimer === null) return;
2018
+ clearInterval(pollTimer);
2019
+ pollTimer = null;
2020
+ };
2021
+ const poll = () => {
2022
+ if (closed) return;
2023
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
2024
+ const backlog = polls === 0;
2025
+ polls += 1;
2026
+ fetchEvents(sessionId, getSince()).then((events) => {
2027
+ if (!closed) onBatch({
2028
+ events,
2029
+ backlog
2030
+ });
2031
+ }).catch(() => {});
2032
+ };
2033
+ const startPolling = () => {
2034
+ if (closed || pollTimer !== null) return;
2035
+ polls = 0;
2036
+ poll();
2037
+ pollTimer = setInterval(poll, FALLBACK_POLL_MS);
2038
+ };
2039
+ const connect = () => {
2040
+ if (closed) return;
2041
+ const query = `?sessionId=${encodeURIComponent(sessionId)}&since=${getSince()}`;
2042
+ const next = new EventSource(`${STREAM_ROUTE}${query}`);
2043
+ source = next;
2044
+ next.onopen = () => {
2045
+ retries = 0;
2046
+ stopPolling();
2047
+ };
2048
+ next.onmessage = (message) => {
2049
+ try {
2050
+ const body = JSON.parse(message.data);
2051
+ if (Array.isArray(body.events)) onBatch({
2052
+ events: body.events,
2053
+ backlog: body.backlog === true
2054
+ });
2055
+ } catch {}
2056
+ };
2057
+ next.onerror = () => {
2058
+ if (source === next) source = null;
2059
+ next.close();
2060
+ if (closed) return;
2061
+ retries += 1;
2062
+ startPolling();
2063
+ if (retries <= STREAM_RETRIES) setTimeout(connect, STREAM_RETRY_MS);
2064
+ };
2065
+ };
2066
+ if (typeof EventSource === "undefined") startPolling();
2067
+ else connect();
2068
+ return () => {
2069
+ closed = true;
2070
+ source?.close();
2071
+ source = null;
2072
+ stopPolling();
2073
+ };
2074
+ }
1543
2075
  /** Resolve the current session id from slot props (top level, nested, or hook). */
1544
2076
  function resolveSessionId(props) {
1545
2077
  const direct = props?.sessionId;
@@ -1594,6 +2126,12 @@ window.__ModuleLoader__.load({
1594
2126
  tag: "CANCELLED",
1595
2127
  sticky: false
1596
2128
  };
2129
+ case "stand-down": return {
2130
+ color: "var(--dsw-alias-state-warn-label, #b9770e)",
2131
+ bg: "var(--dsw-alias-state-warn-tertiary, rgba(185,119,14,0.14))",
2132
+ tag: "GATE OFF",
2133
+ sticky: true
2134
+ };
1597
2135
  default: return {
1598
2136
  color: "var(--dsw-alias-state-success-primary, #1e8449)",
1599
2137
  bg: "var(--dsw-alias-state-success-tertiary, rgba(30,132,73,0.08))",
@@ -1623,7 +2161,9 @@ window.__ModuleLoader__.load({
1623
2161
  "human-approved": "人工通过",
1624
2162
  "human-rejected": "人工拒绝",
1625
2163
  "human-cancelled": "人工取消",
1626
- "no-approval-channel": "无审批通道"
2164
+ "no-approval-channel": "无审批通道",
2165
+ "preset-passthrough": "审批策略 never · 已放行",
2166
+ "stand-down": "门禁停用"
1627
2167
  };
1628
2168
  /** Compact wall-clock rendering of an ISO timestamp. */
1629
2169
  function clockTime(iso) {
@@ -1718,15 +2258,15 @@ window.__ModuleLoader__.load({
1718
2258
  * dsh-perm-gate (a simplified version of the review-feed pattern demonstrated
1719
2259
  * by dsh-approval-gate).
1720
2260
  *
1721
- * Polls the host's `GET /api/dsh-perm-gate/events?sessionId=&since=` feed every
1722
- * 2 s and shows the latest decision above the conversation input: auto-allows
1723
- * (green) and denies (red) auto-dismiss after a few seconds; an ask (amber)
1724
- * stays until the next event because it needs the human's attention.
2261
+ * Follows the host's decision feed (`GET /api/dsh-perm-gate/stream`, an SSE
2262
+ * push; see `followEvents` for the older-host polling fallback) and shows the
2263
+ * latest decision above the conversation input: auto-allows (green) and denies
2264
+ * (red) auto-dismiss after a few seconds; an ask (amber) stays until the next
2265
+ * event because it needs the human's attention.
1725
2266
  *
1726
2267
  * Everything is best-effort: no session id, a missing route, or any fetch
1727
2268
  * failure simply means "render nothing". No @deepseek-ai value imports.
1728
2269
  */
1729
- const POLL_MS$1 = 2e3;
1730
2270
  const AUTO_HIDE_MS = 4e3;
1731
2271
  /** Localized tag for a manual terminal state; other kinds keep their short tag. */
1732
2272
  function manualTagKey(kind) {
@@ -1747,35 +2287,27 @@ window.__ModuleLoader__.load({
1747
2287
  setNotice(null);
1748
2288
  if (sessionId === null) return;
1749
2289
  let alive = true;
1750
- let timer = null;
1751
- const startPolling = () => {
1752
- if (!alive) return;
1753
- timer = setInterval(() => {
1754
- fetchEvents(sessionId, sinceRef.current).then((events) => {
1755
- if (!alive || events.length === 0) return;
1756
- const last = events[events.length - 1];
1757
- if (last === void 0 || last.id <= shownIdRef.current) return;
1758
- shownIdRef.current = last.id;
1759
- sinceRef.current = last.id;
1760
- setNotice(last);
1761
- if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current);
1762
- if (!presentation(last.kind).sticky) hideTimerRef.current = setTimeout(() => {
1763
- setNotice(null);
1764
- }, AUTO_HIDE_MS);
1765
- }).catch(() => {});
1766
- }, POLL_MS$1);
1767
- };
1768
- fetchEvents(sessionId, 0).then((events) => {
1769
- if (!alive) return;
1770
- if (events.length > 0) {
1771
- sinceRef.current = events[events.length - 1]?.id ?? 0;
2290
+ const onBatch = (batch) => {
2291
+ if (!alive || batch.events.length === 0) return;
2292
+ const last = batch.events[batch.events.length - 1];
2293
+ if (last === void 0) return;
2294
+ if (last.id > sinceRef.current) sinceRef.current = last.id;
2295
+ if (batch.backlog) {
1772
2296
  shownIdRef.current = sinceRef.current;
2297
+ return;
1773
2298
  }
1774
- startPolling();
1775
- }).catch(startPolling);
2299
+ if (last.id <= shownIdRef.current) return;
2300
+ shownIdRef.current = last.id;
2301
+ setNotice(last);
2302
+ if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current);
2303
+ if (!presentation(last.kind).sticky) hideTimerRef.current = setTimeout(() => {
2304
+ setNotice(null);
2305
+ }, AUTO_HIDE_MS);
2306
+ };
2307
+ const close = followEvents(sessionId, () => sinceRef.current, onBatch);
1776
2308
  return () => {
1777
2309
  alive = false;
1778
- if (timer !== null) clearInterval(timer);
2310
+ close();
1779
2311
  if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current);
1780
2312
  };
1781
2313
  }, [sessionId]);
@@ -1858,7 +2390,8 @@ window.__ModuleLoader__.load({
1858
2390
  * Approval-history view — the `conversation.view` face of dsh-perm-gate,
1859
2391
  * following the session-scoped review-page pattern demonstrated by
1860
2392
  * dsh-approval-gate: a timeline of every gate decision in the current
1861
- * conversation, newest first, refreshed by polling.
2393
+ * conversation, newest first, loaded once and then extended by the host's push
2394
+ * feed (see `followEvents`).
1862
2395
  *
1863
2396
  * Beyond the decision timeline this view owns the review data plane: a snapshot
1864
2397
  * inventory bar (with session/all clearing), per-event file chips, and a diff
@@ -1870,7 +2403,6 @@ window.__ModuleLoader__.load({
1870
2403
  * routes. Everything is best-effort: no session id or any fetch failure renders
1871
2404
  * the empty / error state. No @deepseek-ai value imports.
1872
2405
  */
1873
- const POLL_MS = 5e3;
1874
2406
  /** Tag text per event kind, keyed into the plugin dictionary. */
1875
2407
  const TAG_KEYS = {
1876
2408
  auto: "history.tag.auto",
@@ -1879,7 +2411,8 @@ window.__ModuleLoader__.load({
1879
2411
  learned: "history.tag.learned",
1880
2412
  "manual-approved": "history.tag.manualApproved",
1881
2413
  "manual-rejected": "history.tag.manualRejected",
1882
- "manual-cancelled": "history.tag.manualCancelled"
2414
+ "manual-cancelled": "history.tag.manualCancelled",
2415
+ "stand-down": "history.tag.standDown"
1883
2416
  };
1884
2417
  /** The file name alone (chips show the basename, like the reference page). */
1885
2418
  function basename(p) {
@@ -2283,29 +2816,47 @@ window.__ModuleLoader__.load({
2283
2816
  setEvents(null);
2284
2817
  setError(null);
2285
2818
  setStats(null);
2819
+ if (sessionId === null) {
2820
+ setEvents([]);
2821
+ return;
2822
+ }
2286
2823
  let alive = true;
2287
- const load = () => {
2824
+ let cursor = 0;
2825
+ let closeStream = null;
2826
+ fetchSnapshotStats(sessionId).then((next) => {
2827
+ if (alive) setStats(next);
2828
+ });
2829
+ /**
2830
+ * Decisions landing after the initial load. Both the stream's pushes and the
2831
+ * fallback's ticks carry only events past the cursor, so they merge the same
2832
+ * way; a batch is also the point where the snapshot inventory can have grown.
2833
+ */
2834
+ const onBatch = (batch) => {
2835
+ if (!alive || batch.events.length === 0) return;
2836
+ for (const ev of batch.events) if (ev.id > cursor) cursor = ev.id;
2837
+ setEvents((current) => {
2838
+ const known = new Set((current ?? []).map((ev) => ev.id));
2839
+ const added = batch.events.filter((ev) => !known.has(ev.id));
2840
+ return added.length === 0 ? current : [...added, ...current ?? []].sort((a, b) => b.id - a.id);
2841
+ });
2842
+ setError(null);
2288
2843
  fetchSnapshotStats(sessionId).then((next) => {
2289
2844
  if (alive) setStats(next);
2290
2845
  });
2291
- if (sessionId === null) {
2292
- setEvents([]);
2293
- return;
2294
- }
2295
- fetchEvents(sessionId, 0).then((loaded) => {
2296
- if (!alive) return;
2297
- setEvents([...loaded].sort((a, b) => b.id - a.id));
2298
- setError(null);
2299
- }).catch((e) => {
2300
- if (!alive) return;
2301
- setError(String(e?.message ?? e));
2302
- });
2303
2846
  };
2304
- load();
2305
- const timer = setInterval(load, POLL_MS);
2847
+ fetchEvents(sessionId, 0).then((loaded) => {
2848
+ if (!alive) return;
2849
+ setEvents([...loaded].sort((a, b) => b.id - a.id));
2850
+ setError(null);
2851
+ cursor = loaded.reduce((max, ev) => ev.id > max ? ev.id : max, 0);
2852
+ }).catch((e) => {
2853
+ if (alive) setError(String(e?.message ?? e));
2854
+ }).finally(() => {
2855
+ if (alive) closeStream = followEvents(sessionId, () => cursor, onBatch);
2856
+ });
2306
2857
  return () => {
2307
2858
  alive = false;
2308
- clearInterval(timer);
2859
+ closeStream?.();
2309
2860
  };
2310
2861
  }, [sessionId]);
2311
2862
  /** Whether one event has a snapshot covering this file (chip is clickable). */