dsh-perm-gate 3.0.0 → 4.1.0-beta.1

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.
package/INSTALL.ja.md CHANGED
@@ -12,7 +12,7 @@
12
12
  - [日本語 changelog](./CHANGELOG.ja.md)
13
13
  - [한국어 changelog](./CHANGELOG.ko.md)
14
14
 
15
- `dsh-perm-gate` バージョン **2.6.0**。判定チェーン、ルールファイル形式、自动审查
15
+ `dsh-perm-gate` バージョン **2.4.1**。判定チェーン、ルールファイル形式、自动审查
16
16
  ティアについては [日本語 README](./README.ja.md) を参照してください。
17
17
 
18
18
  ## 必要条件
@@ -23,20 +23,19 @@
23
23
  - インストール先の profile。ブラウザ側は `web` profile 向けにのみビルドされます
24
24
  (`package.json` の `dsh.client.platform = "web"`)。
25
25
 
26
- ## DSH のバージョンに合うラインを選ぶ
26
+ ## DSH のバージョンに合う tag を選ぶ
27
27
 
28
- 2 つの長期ブランチが 2 つの DSH ラインを担当します:`main`(2.x)と
29
- `sync/0.1.5-from-main`(3.x、DSH 0.1.5 のパッケージング変体 —— `compat/0.1.5` は
30
- 同じコードツリーを指します)。
28
+ 単一ビルドが DSH 両ラインをカバーするため、どちらの tag を選んでも動作するコードが
29
+ インストールされます —— tag を残しておくのは、あなたがピン留めするバージョンが
30
+ 各ラインで意味を持つようにするためです。
31
31
 
32
32
  | DSH のバージョン | インストール方法 |
33
33
  |-----------------|-----------------|
34
- | `0.1.2-alpha.1` – `0.1.5-rc.0` | `dsh plugin --profile web add dsh-perm-gate`(tag `latest`、2.x ライン) |
35
- | `0.1.5-rc.1` 以上 | `dsh plugin --profile web add github:drscrewdriver/dsh-perm-gate#sync/0.1.5-from-main`(3.x ライン;npm の dist-tag は未公開 —— `@latest` は同じコードの 2.x パッケージングをインストールします) |
34
+ | `0.1.2-alpha.1` 以上(`0.1.5-rc.2` を含む) | `dsh plugin --profile web add dsh-perm-gate`(tag `latest`) |
36
35
  | `0.1.1-rc.2` まで | `dsh plugin --profile web add dsh-perm-gate@legacy` |
37
36
 
38
- DSH は `engines.dsh` を強制しないため、tag と ref は選択メカニズムであり、互換性
39
- ゲートそのものではありません。ラインと tag の公開レイアウトについては
37
+ DSH は `engines.dsh` を強制しないため、tag が選択メカニズムであり、互換性ゲート
38
+ そのものではありません。単一ビルドが両ラインをカバーする理由と tag の公開方法については、
40
39
  [RELEASING.md](./RELEASING.md) を参照してください。
41
40
 
42
41
  ## 公式 CLI でインストール
@@ -73,11 +72,6 @@ profile の `cordis.yml` にプラグインを追加します:
73
72
  defaultAction: ask # allow | ask | deny
74
73
  ```
75
74
 
76
- 2.6.0 から、ルール本体は `dsh-perm-gate-rules` settings 名前空間に保存され、変更の
77
- たびにゲートがその場で再コンパイルされます。上記の YAML ファイルは任意になりました。
78
- 名前空間が未設定の間だけ読み込まれ、プラグインが最初にルールを書き込む際(許可リスト
79
- の承認、または設定 UI での編集)にファイルのルールが自動的に取り込まれます。
80
-
81
75
  まずは [examples/permissions.example.yaml](./examples/permissions.example.yaml)
82
76
  から始め、profile を再読込してください。
83
77
 
package/INSTALL.ko.md CHANGED
@@ -12,7 +12,7 @@
12
12
  - [日本語 changelog](./CHANGELOG.ja.md)
13
13
  - [한국어 changelog](./CHANGELOG.ko.md)
14
14
 
15
- `dsh-perm-gate` 버전 **2.6.0**. 결정 체인, 규칙 파일 형식, 自动审查 티어는
15
+ `dsh-perm-gate` 버전 **2.4.1**. 결정 체인, 규칙 파일 형식, 自动审查 티어는
16
16
  [한국어 README](./README.ko.md)를 참고하세요.
17
17
 
18
18
  ## 요구 사항
@@ -23,21 +23,20 @@
23
23
  - 설치할 profile. 브라우저 절반은 `web` profile용으로만 빌드됩니다
24
24
  (`package.json`의 `dsh.client.platform = "web"`).
25
25
 
26
- ## DSH 버전에 맞는 라인 선택
26
+ ## DSH 버전에 맞는 tag 선택
27
27
 
28
- 두 개의 장기 브랜치가 두 DSH 라인을 담당합니다: `main`(2.x)과
29
- `sync/0.1.5-from-main`(3.x, DSH 0.1.5 패키징 변형 —— `compat/0.1.5`는 같은 코드
30
- 트리를 가리킵니다).
28
+ 단일 빌드는 두 DSH 라인을 모두 커버하므로, 어느 tag를 골라도 작동하는 코드가
29
+ 설치됩니다 —— tag를 유지하는 이유는 당신이 고정하는 버전이 각 라인에서 의미 있게
30
+ 유지되도록 하기 위함입니다.
31
31
 
32
32
  | DSH 버전 | 설치 방법 |
33
33
  |----------|-----------|
34
- | `0.1.2-alpha.1` – `0.1.5-rc.0` | `dsh plugin --profile web add dsh-perm-gate` (tag `latest`, 2.x 라인) |
35
- | `0.1.5-rc.1` 이상 | `dsh plugin --profile web add github:drscrewdriver/dsh-perm-gate#sync/0.1.5-from-main` (3.x 라인; npm dist-tag 미게시 —— `@latest`는 동일 코드의 2.x 패키징을 설치) |
34
+ | `0.1.2-alpha.1` 이상 (`0.1.5-rc.2` 포함) | `dsh plugin --profile web add dsh-perm-gate` (tag `latest`) |
36
35
  | `0.1.1-rc.2` 이하 | `dsh plugin --profile web add dsh-perm-gate@legacy` |
37
36
 
38
- DSH는 `engines.dsh`를 강제하지 않으므로, tag과 ref은 선택 메커니즘이지 호환성
39
- 게이트가 아닙니다. 라인과 tag 게시 레이아웃은 [RELEASING.md](./RELEASING.md)를
40
- 참고하세요.
37
+ DSH는 `engines.dsh`를 강제하지 않으므로, tag는 선택 메커니즘이지 호환성 게이트가
38
+ 아닙니다. 단일 아티팩트가 두 라인을 커버하는 이유와 tag가 게시되는 방식에 대해서는
39
+ [RELEASING.md](./RELEASING.md)를 참고하세요.
41
40
 
42
41
  ## 공식 CLI로 설치
43
42
 
@@ -74,11 +73,6 @@ profile의 `cordis.yml`에 플러그인을 추가합니다:
74
73
  defaultAction: ask # allow | ask | deny
75
74
  ```
76
75
 
77
- 2.6.0부터 규칙 본체는 `dsh-perm-gate-rules` settings 네임스페이스에 저장되며, 변경 시
78
- 마다 게이트가 그 자리에서 다시 컴파일됩니다. 위의 YAML 파일은 선택 사항이 되었습니다.
79
- 네임스페이스가 설정되지 않은 동안에만 읽히며, 플러그인이 처음으로 규칙을 쓸 때(허용
80
- 목록 승인 또는 설정 UI에서의 편집) 파일의 규칙이 자동으로 흡수됩니다.
81
-
82
76
  [examples/permissions.example.yaml](./examples/permissions.example.yaml)에서 시작한 뒤
83
77
  profile을 다시 불러오세요.
84
78
 
package/INSTALL.md CHANGED
@@ -12,7 +12,7 @@
12
12
  - [日本語 changelog](./CHANGELOG.ja.md)
13
13
  - [한국어 changelog](./CHANGELOG.ko.md)
14
14
 
15
- `dsh-perm-gate` version **2.6.0**. Continue with the [README](./README.md) for the
15
+ `dsh-perm-gate` version **2.4.1**. Continue with the [README](./README.md) for the
16
16
  decision chain, the rules file format and the 自动审查 tier.
17
17
 
18
18
  ## Requirements
@@ -23,21 +23,19 @@ decision chain, the rules file format and the 自动审查 tier.
23
23
  - A profile to install into — the browser half only ships for the `web` profile
24
24
  (`dsh.client.platform = "web"` in `package.json`).
25
25
 
26
- ## Pick the line for your DSH version
26
+ ## Pick the tag for your DSH version
27
27
 
28
- Two long-lived branches serve two DSH lines: `main` (2.x) and
29
- `sync/0.1.5-from-main` (3.x, the DSH 0.1.5 packaging variant — `compat/0.1.5`
30
- points at the same tree).
28
+ One build serves both DSH lines, so either tag installs working code — the tag
29
+ exists so a version you pin stays meaningful per line.
31
30
 
32
31
  | Your DSH | Install |
33
32
  |----------|---------|
34
- | `0.1.2-alpha.1` – `0.1.5-rc.0` | `dsh plugin --profile web add dsh-perm-gate` (tag `latest`, the 2.x line) |
35
- | `0.1.5-rc.1` or newer | `dsh plugin --profile web add github:drscrewdriver/dsh-perm-gate#sync/0.1.5-from-main` (the 3.x line; no npm dist-tag yet — `@latest` installs the same code with 2.x packaging) |
33
+ | `0.1.2-alpha.1` or newer (incl. `0.1.5-rc.2`) | `dsh plugin --profile web add dsh-perm-gate` (tag `latest`) |
36
34
  | up to `0.1.1-rc.2` | `dsh plugin --profile web add dsh-perm-gate@legacy` |
37
35
 
38
- DSH does not enforce `engines.dsh`, so tags and refs are the selection mechanism
39
- rather than a compatibility gate. See [RELEASING.md](./RELEASING.md) for how the
40
- lines and tags are laid out.
36
+ DSH does not enforce `engines.dsh`, so the tags are the selection mechanism rather
37
+ than a compatibility gate. See [RELEASING.md](./RELEASING.md) for why one artifact
38
+ covers both lines and how the tags are published.
41
39
 
42
40
  ## Install with the official CLI
43
41
 
@@ -75,12 +73,6 @@ Add the plugin to your profile's `cordis.yml`:
75
73
  gatePresets: [permissive] # tiers where the gate is active at all (default)
76
74
  ```
77
75
 
78
- Since 2.6.0 the rules themselves are stored in the `dsh-perm-gate-rules` settings
79
- namespace, and every change recompiles the gate in place. The YAML file above is
80
- optional: it is only read while the namespace is untouched, and the first rules
81
- write from the plugin (an allowlist approval, or an edit in the settings UI)
82
- absorbs the file's rules automatically.
83
-
84
76
  Start from [examples/permissions.example.yaml](./examples/permissions.example.yaml),
85
77
  then reload the profile.
86
78
 
package/INSTALL.zh.md CHANGED
@@ -12,7 +12,7 @@
12
12
  - [日本語 changelog](./CHANGELOG.ja.md)
13
13
  - [한국어 changelog](./CHANGELOG.ko.md)
14
14
 
15
- `dsh-perm-gate` 版本 **2.6.0**。裁决链、规则文件格式与自动审查档位请见
15
+ `dsh-perm-gate` 版本 **2.4.1**。裁决链、规则文件格式与自动审查档位请见
16
16
  [中文 README](./README.zh.md)。
17
17
 
18
18
  ## 前置条件
@@ -23,19 +23,18 @@
23
23
  - 需要一个目标 profile —— 浏览器端只为 `web` profile 构建(`package.json`
24
24
  里 `dsh.client.platform = "web"`)。
25
25
 
26
- ## 按你的 DSH 版本选择安装线
26
+ ## 按你的 DSH 版本选择 tag
27
27
 
28
- 两条长期分支分别服务两条 DSH 线:`main`(2.x)与 `sync/0.1.5-from-main`
29
- (3.x,DSH 0.1.5 打包变体 —— `compat/0.1.5` 指向同一棵代码树)。
28
+ 单一构建同时覆盖两条 DSH 线,所以任一 tag 安装的代码都能工作 —— 保留 tag
29
+ 是为了让你锁定的版本在每条线上都有意义。
30
30
 
31
31
  | 你的 DSH | 安装方式 |
32
32
  |----------|----------|
33
- | `0.1.2-alpha.1` – `0.1.5-rc.0` | `dsh plugin --profile web add dsh-perm-gate`(tag `latest`,2.x 线) |
34
- | `0.1.5-rc.1` 或更高 | `dsh plugin --profile web add github:drscrewdriver/dsh-perm-gate#sync/0.1.5-from-main`(3.x 线;npm 尚无对应 dist-tag —— `@latest` 安装的是相同代码的 2.x 打包) |
33
+ | `0.1.2-alpha.1` 或更高(含 `0.1.5-rc.2`) | `dsh plugin --profile web add dsh-perm-gate`(tag `latest`) |
35
34
  | 不高于 `0.1.1-rc.2` | `dsh plugin --profile web add dsh-perm-gate@legacy` |
36
35
 
37
- DSH 不强制 `engines.dsh`,因此 tag 与 ref 是选择机制而非兼容性关卡。
38
- 各线与 tag 的发布布局详见 [RELEASING.md](./RELEASING.md)。
36
+ DSH 不强制 `engines.dsh`,因此 tag 是选择机制而非兼容性关卡。
37
+ 详见 [RELEASING.md](./RELEASING.md) 了解为何单一制品覆盖两条线以及 tag 的发布方式。
39
38
 
40
39
  ## 用官方 CLI 安装
41
40
 
@@ -71,10 +70,6 @@ dsh plugin --profile web add .
71
70
  defaultAction: ask # allow | ask | deny
72
71
  ```
73
72
 
74
- 自 2.6.0 起,规则本体存储在 `dsh-perm-gate-rules` settings 命名空间中,每次变更都会
75
- 就地重编译闸门。上面的 YAML 文件变为可选:只有命名空间未被配置时才会读取它,插件
76
- 发起的首次规则写入(白名单批准或设置界面编辑)会自动收编文件中的规则。
77
-
78
73
  可从 [examples/permissions.example.yaml](./examples/permissions.example.yaml) 起步,
79
74
  然后重载 profile。
80
75
 
package/README.ja.md CHANGED
@@ -27,8 +27,7 @@
27
27
  > | DSH バージョン | ブランチ | バージョン | npm タグ |
28
28
  > | --- | --- | --- | --- |
29
29
  > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
30
- > | 0.1.2-alpha.1 ~ 0.1.5-rc.0 | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` は範囲) |
31
- > | 0.1.5-rc.1+ | `sync/0.1.5-from-main`(=`compat/0.1.5`) | `3.x` | GitHub ref インストール(dist-tag 未公開) |
30
+ > | 0.1.2-alpha.1+(0.1.5-rc.2を含む) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` は範囲) |
32
31
  >
33
32
  > バージョン系列は **DSH ライン**を追います(`1.x` = DSH ≤ 0.1.1、`2.x` = DSH 0.1.2+)。
34
33
  > メジャー同士が互いを遮断するため、`^1.x` のインストールが `2.x` を解決することは
@@ -46,7 +45,7 @@
46
45
  > メソッドであるため、`typeof` プロブで読み取り、欠落時やエラー時には「ポリシー
47
46
  > 不明」にフォールバックします。
48
47
 
49
- バージョン **2.6.0** — 変更履歴は [日本語 changelog](./CHANGELOG.ja.md) を参照。
48
+ バージョン **2.4.1** — 変更履歴は [日本語 changelog](./CHANGELOG.ja.md) を参照。
50
49
 
51
50
  DeepSeek Harness 向けの、単一・自己完結・決定論優先・fail-closed な権限ゲートです。
52
51
 
package/README.ko.md CHANGED
@@ -27,8 +27,7 @@
27
27
  > | DSH 버전 | 브랜치 | 버전 | npm 태그 |
28
28
  > | --- | --- | --- | --- |
29
29
  > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
30
- > | 0.1.2-alpha.1 ~ 0.1.5-rc.0 | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x`는 범위) |
31
- > | 0.1.5-rc.1+ | `sync/0.1.5-from-main` (=`compat/0.1.5`) | `3.x` | GitHub ref 설치 (dist-tag 미게시) |
30
+ > | 0.1.2-alpha.1+ (0.1.5-rc.2 포함) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x`는 범위) |
32
31
  >
33
32
  > 버전 계열은 **DSH 라인**을 따릅니다(`1.x` = DSH ≤ 0.1.1, `2.x` = DSH 0.1.2+). 메이저
34
33
  > 버전이 서로를 차단하므로 `^1.x` 설치가 `2.x`를 해석하는 일은 없고 그 반대도
@@ -44,7 +43,7 @@
44
43
  > 모두에서 user-approval 서비스의 **개인** 메소드이므로 `typeof` 프로브를 통해
45
44
  > 읽으며, 누락되거나 예외 발생 시 "정책_unknown"으로 후퇴합니다.
46
45
 
47
- 버전 **2.6.0** — 변경 내역은 [한국어 changelog](./CHANGELOG.ko.md)를 참고하세요.
46
+ 버전 **2.4.1** — 변경 내역은 [한국어 changelog](./CHANGELOG.ko.md)를 참고하세요.
48
47
 
49
48
  DeepSeek Harness용 단일·자족적·결정론 우선·fail-closed 권한 게이트입니다.
50
49
 
package/README.md CHANGED
@@ -26,8 +26,7 @@
26
26
  > | DSH version | Branch | Version | npm tag |
27
27
  > | --- | --- | --- | --- |
28
28
  > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
29
- > | 0.1.2-alpha.1 ~ 0.1.5-rc.0 | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` is a range) |
30
- > | 0.1.5-rc.1+ | `sync/0.1.5-from-main` (=`compat/0.1.5`) | `3.x` | GitHub ref install (no dist-tag yet) |
29
+ > | 0.1.2-alpha.1+ (incl. 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` is a range) |
31
30
  >
32
31
  > The series number tracks the **DSH line** (`1.x` = DSH ≤ 0.1.1, `2.x` = DSH 0.1.2+),
33
32
  > and the majors fence each other: a `^1.x` install never resolves a `2.x` release and
package/README.zh.md CHANGED
@@ -25,8 +25,7 @@
25
25
  > | DSH 版本 | 分支 | 版本号 | npm 标签 |
26
26
  > | --- | --- | --- | --- |
27
27
  > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
28
- > | 0.1.2-alpha.1 ~ 0.1.5-rc.0 | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` 是范围) |
29
- > | 0.1.5-rc.1+ | `sync/0.1.5-from-main`(=`compat/0.1.5`) | `3.x` | GitHub ref 安装(暂无 dist-tag) |
28
+ > | 0.1.2-alpha.1+(含 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` 是范围) |
30
29
  >
31
30
  > 版本序列号跟的是 **DSH 线**(`1.x` = DSH ≤ 0.1.1,`2.x` = DSH 0.1.2+),两条大版本互
32
31
  > 相隔离:锁在 `^1.x` 的安装绝不会解析到 `2.x`,反之亦然。`engines.dsh` 表达同样的
@@ -40,7 +39,7 @@
40
39
  > 线上都是 user-approval 服务的**私有**方法,因此通过 `typeof` 探测读取,缺失
41
40
  > 或抛错时降级为「策略未知」。
42
41
 
43
- 版本 **2.6.0** —— 变更见 [Changelog](./CHANGELOG.md)。
42
+ 版本 **2.4.1** —— 变更见 [Changelog](./CHANGELOG.md)。
44
43
 
45
44
  一个**单一自足、确定性优先、fail-closed** 的 DeepSeek Harness 权限门插件。
46
45
 
package/lib/client.js CHANGED
@@ -405,7 +405,6 @@ window.__ModuleLoader__.load({
405
405
  * learning.json store the gate reads); actions POST back. Best-effort: a
406
406
  * missing route renders the empty state, never an error.
407
407
  */
408
- const POLL_MS$2 = 5e3;
409
408
  const itemStyle = {
410
409
  display: "flex",
411
410
  alignItems: "center",
@@ -435,6 +434,8 @@ window.__ModuleLoader__.load({
435
434
  };
436
435
  function SedimentSection({ t }) {
437
436
  const [body, setBody] = (0, react.useState)(null);
437
+ /** Latest loader, so a mutation can refresh the list without owning the fetch. */
438
+ const reloadRef = (0, react.useRef)(() => {});
438
439
  (0, react.useEffect)(() => {
439
440
  let alive = true;
440
441
  const load = () => {
@@ -444,11 +445,18 @@ window.__ModuleLoader__.load({
444
445
  if (alive) setBody({});
445
446
  });
446
447
  };
448
+ reloadRef.current = load;
447
449
  load();
448
- const timer = setInterval(load, POLL_MS$2);
450
+ const onVisible = () => {
451
+ if (document.visibilityState === "visible") load();
452
+ };
453
+ window.addEventListener("focus", onVisible);
454
+ document.addEventListener("visibilitychange", onVisible);
449
455
  return () => {
450
456
  alive = false;
451
- clearInterval(timer);
457
+ reloadRef.current = () => {};
458
+ window.removeEventListener("focus", onVisible);
459
+ document.removeEventListener("visibilitychange", onVisible);
452
460
  };
453
461
  }, []);
454
462
  const post = (payload) => {
@@ -456,6 +464,8 @@ window.__ModuleLoader__.load({
456
464
  method: "POST",
457
465
  headers: { "content-type": "application/json" },
458
466
  body: JSON.stringify(payload)
467
+ }).then(() => {
468
+ reloadRef.current();
459
469
  }).catch(() => {});
460
470
  };
461
471
  const threshold = body?.threshold ?? 3;
@@ -2009,6 +2019,95 @@ window.__ModuleLoader__.load({
2009
2019
  const body = await res.json();
2010
2020
  return Array.isArray(body.events) ? body.events : [];
2011
2021
  }
2022
+ /** The host's push route (see src/events.ts); the browser half prefers it to polling. */
2023
+ const STREAM_ROUTE = "/api/dsh-perm-gate/stream";
2024
+ /** Stream attempts before the route is written off as unsupported and polling takes over. */
2025
+ const STREAM_RETRIES = 3;
2026
+ const STREAM_RETRY_MS = 3e3;
2027
+ /**
2028
+ * Degraded path only: a host predating the stream route still gets a servable
2029
+ * strip. Deliberately slower than the old 2 s tick — it now also pauses while
2030
+ * the tab is hidden, so a backgrounded page costs nothing.
2031
+ */
2032
+ const FALLBACK_POLL_MS = 5e3;
2033
+ /**
2034
+ * Follow one session's decision feed until the returned close is called.
2035
+ *
2036
+ * Prefers the host's `text/event-stream` route, which pushes each decision as
2037
+ * the gate appends it — no timer, no repeated re-read of the event log. When
2038
+ * the stream is unavailable (an older host, a proxy that buffers it) this
2039
+ * degrades to polling the JSON route, gated on page visibility.
2040
+ *
2041
+ * @param sessionId - the session whose decisions to follow.
2042
+ * @param getSince - cursor supplier, read at connect and on every fallback poll.
2043
+ * @param onBatch - receives the backlog, then live batches.
2044
+ * @returns the close: ends the stream and any fallback timer.
2045
+ */
2046
+ function followEvents(sessionId, getSince, onBatch) {
2047
+ let closed = false;
2048
+ let source = null;
2049
+ let pollTimer = null;
2050
+ let retries = 0;
2051
+ let polls = 0;
2052
+ const stopPolling = () => {
2053
+ if (pollTimer === null) return;
2054
+ clearInterval(pollTimer);
2055
+ pollTimer = null;
2056
+ };
2057
+ const poll = () => {
2058
+ if (closed) return;
2059
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
2060
+ const backlog = polls === 0;
2061
+ polls += 1;
2062
+ fetchEvents(sessionId, getSince()).then((events) => {
2063
+ if (!closed) onBatch({
2064
+ events,
2065
+ backlog
2066
+ });
2067
+ }).catch(() => {});
2068
+ };
2069
+ const startPolling = () => {
2070
+ if (closed || pollTimer !== null) return;
2071
+ polls = 0;
2072
+ poll();
2073
+ pollTimer = setInterval(poll, FALLBACK_POLL_MS);
2074
+ };
2075
+ const connect = () => {
2076
+ if (closed) return;
2077
+ const query = `?sessionId=${encodeURIComponent(sessionId)}&since=${getSince()}`;
2078
+ const next = new EventSource(`${STREAM_ROUTE}${query}`);
2079
+ source = next;
2080
+ next.onopen = () => {
2081
+ retries = 0;
2082
+ stopPolling();
2083
+ };
2084
+ next.onmessage = (message) => {
2085
+ try {
2086
+ const body = JSON.parse(message.data);
2087
+ if (Array.isArray(body.events)) onBatch({
2088
+ events: body.events,
2089
+ backlog: body.backlog === true
2090
+ });
2091
+ } catch {}
2092
+ };
2093
+ next.onerror = () => {
2094
+ if (source === next) source = null;
2095
+ next.close();
2096
+ if (closed) return;
2097
+ retries += 1;
2098
+ startPolling();
2099
+ if (retries <= STREAM_RETRIES) setTimeout(connect, STREAM_RETRY_MS);
2100
+ };
2101
+ };
2102
+ if (typeof EventSource === "undefined") startPolling();
2103
+ else connect();
2104
+ return () => {
2105
+ closed = true;
2106
+ source?.close();
2107
+ source = null;
2108
+ stopPolling();
2109
+ };
2110
+ }
2012
2111
  /** Resolve the current session id from slot props (top level, nested, or hook). */
2013
2112
  function resolveSessionId(props) {
2014
2113
  const direct = props?.sessionId;
@@ -2195,15 +2294,15 @@ window.__ModuleLoader__.load({
2195
2294
  * dsh-perm-gate (a simplified version of the review-feed pattern demonstrated
2196
2295
  * by dsh-approval-gate).
2197
2296
  *
2198
- * Polls the host's `GET /api/dsh-perm-gate/events?sessionId=&since=` feed every
2199
- * 2 s and shows the latest decision above the conversation input: auto-allows
2200
- * (green) and denies (red) auto-dismiss after a few seconds; an ask (amber)
2201
- * stays until the next event because it needs the human's attention.
2297
+ * Follows the host's decision feed (`GET /api/dsh-perm-gate/stream`, an SSE
2298
+ * push; see `followEvents` for the older-host polling fallback) and shows the
2299
+ * latest decision above the conversation input: auto-allows (green) and denies
2300
+ * (red) auto-dismiss after a few seconds; an ask (amber) stays until the next
2301
+ * event because it needs the human's attention.
2202
2302
  *
2203
2303
  * Everything is best-effort: no session id, a missing route, or any fetch
2204
2304
  * failure simply means "render nothing". No @deepseek-ai value imports.
2205
2305
  */
2206
- const POLL_MS$1 = 2e3;
2207
2306
  const AUTO_HIDE_MS = 4e3;
2208
2307
  /** Localized tag for a manual terminal state; other kinds keep their short tag. */
2209
2308
  function manualTagKey(kind) {
@@ -2224,35 +2323,27 @@ window.__ModuleLoader__.load({
2224
2323
  setNotice(null);
2225
2324
  if (sessionId === null) return;
2226
2325
  let alive = true;
2227
- let timer = null;
2228
- const startPolling = () => {
2229
- if (!alive) return;
2230
- timer = setInterval(() => {
2231
- fetchEvents(sessionId, sinceRef.current).then((events) => {
2232
- if (!alive || events.length === 0) return;
2233
- const last = events[events.length - 1];
2234
- if (last === void 0 || last.id <= shownIdRef.current) return;
2235
- shownIdRef.current = last.id;
2236
- sinceRef.current = last.id;
2237
- setNotice(last);
2238
- if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current);
2239
- if (!presentation(last.kind).sticky) hideTimerRef.current = setTimeout(() => {
2240
- setNotice(null);
2241
- }, AUTO_HIDE_MS);
2242
- }).catch(() => {});
2243
- }, POLL_MS$1);
2244
- };
2245
- fetchEvents(sessionId, 0).then((events) => {
2246
- if (!alive) return;
2247
- if (events.length > 0) {
2248
- sinceRef.current = events[events.length - 1]?.id ?? 0;
2326
+ const onBatch = (batch) => {
2327
+ if (!alive || batch.events.length === 0) return;
2328
+ const last = batch.events[batch.events.length - 1];
2329
+ if (last === void 0) return;
2330
+ if (last.id > sinceRef.current) sinceRef.current = last.id;
2331
+ if (batch.backlog) {
2249
2332
  shownIdRef.current = sinceRef.current;
2333
+ return;
2250
2334
  }
2251
- startPolling();
2252
- }).catch(startPolling);
2335
+ if (last.id <= shownIdRef.current) return;
2336
+ shownIdRef.current = last.id;
2337
+ setNotice(last);
2338
+ if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current);
2339
+ if (!presentation(last.kind).sticky) hideTimerRef.current = setTimeout(() => {
2340
+ setNotice(null);
2341
+ }, AUTO_HIDE_MS);
2342
+ };
2343
+ const close = followEvents(sessionId, () => sinceRef.current, onBatch);
2253
2344
  return () => {
2254
2345
  alive = false;
2255
- if (timer !== null) clearInterval(timer);
2346
+ close();
2256
2347
  if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current);
2257
2348
  };
2258
2349
  }, [sessionId]);
@@ -2335,7 +2426,8 @@ window.__ModuleLoader__.load({
2335
2426
  * Approval-history view — the `conversation.view` face of dsh-perm-gate,
2336
2427
  * following the session-scoped review-page pattern demonstrated by
2337
2428
  * dsh-approval-gate: a timeline of every gate decision in the current
2338
- * conversation, newest first, refreshed by polling.
2429
+ * conversation, newest first, loaded once and then extended by the host's push
2430
+ * feed (see `followEvents`).
2339
2431
  *
2340
2432
  * Beyond the decision timeline this view owns the review data plane: a snapshot
2341
2433
  * inventory bar (with session/all clearing), per-event file chips, and a diff
@@ -2347,7 +2439,6 @@ window.__ModuleLoader__.load({
2347
2439
  * routes. Everything is best-effort: no session id or any fetch failure renders
2348
2440
  * the empty / error state. No @deepseek-ai value imports.
2349
2441
  */
2350
- const POLL_MS = 5e3;
2351
2442
  /** Tag text per event kind, keyed into the plugin dictionary. */
2352
2443
  const TAG_KEYS = {
2353
2444
  auto: "history.tag.auto",
@@ -2761,29 +2852,47 @@ window.__ModuleLoader__.load({
2761
2852
  setEvents(null);
2762
2853
  setError(null);
2763
2854
  setStats(null);
2855
+ if (sessionId === null) {
2856
+ setEvents([]);
2857
+ return;
2858
+ }
2764
2859
  let alive = true;
2765
- const load = () => {
2860
+ let cursor = 0;
2861
+ let closeStream = null;
2862
+ fetchSnapshotStats(sessionId).then((next) => {
2863
+ if (alive) setStats(next);
2864
+ });
2865
+ /**
2866
+ * Decisions landing after the initial load. Both the stream's pushes and the
2867
+ * fallback's ticks carry only events past the cursor, so they merge the same
2868
+ * way; a batch is also the point where the snapshot inventory can have grown.
2869
+ */
2870
+ const onBatch = (batch) => {
2871
+ if (!alive || batch.events.length === 0) return;
2872
+ for (const ev of batch.events) if (ev.id > cursor) cursor = ev.id;
2873
+ setEvents((current) => {
2874
+ const known = new Set((current ?? []).map((ev) => ev.id));
2875
+ const added = batch.events.filter((ev) => !known.has(ev.id));
2876
+ return added.length === 0 ? current : [...added, ...current ?? []].sort((a, b) => b.id - a.id);
2877
+ });
2878
+ setError(null);
2766
2879
  fetchSnapshotStats(sessionId).then((next) => {
2767
2880
  if (alive) setStats(next);
2768
2881
  });
2769
- if (sessionId === null) {
2770
- setEvents([]);
2771
- return;
2772
- }
2773
- fetchEvents(sessionId, 0).then((loaded) => {
2774
- if (!alive) return;
2775
- setEvents([...loaded].sort((a, b) => b.id - a.id));
2776
- setError(null);
2777
- }).catch((e) => {
2778
- if (!alive) return;
2779
- setError(String(e?.message ?? e));
2780
- });
2781
2882
  };
2782
- load();
2783
- const timer = setInterval(load, POLL_MS);
2883
+ fetchEvents(sessionId, 0).then((loaded) => {
2884
+ if (!alive) return;
2885
+ setEvents([...loaded].sort((a, b) => b.id - a.id));
2886
+ setError(null);
2887
+ cursor = loaded.reduce((max, ev) => ev.id > max ? ev.id : max, 0);
2888
+ }).catch((e) => {
2889
+ if (alive) setError(String(e?.message ?? e));
2890
+ }).finally(() => {
2891
+ if (alive) closeStream = followEvents(sessionId, () => cursor, onBatch);
2892
+ });
2784
2893
  return () => {
2785
2894
  alive = false;
2786
- clearInterval(timer);
2895
+ closeStream?.();
2787
2896
  };
2788
2897
  }, [sessionId]);
2789
2898
  /** Whether one event has a snapshot covering this file (chip is clickable). */