@nolit/dsh-notify 0.0.0-stage → 0.0.1-rc.4

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 (38) hide show
  1. package/CHANGELOG.md +445 -0
  2. package/LICENSE +21 -0
  3. package/PROVENANCE.md +96 -0
  4. package/README.md +331 -2
  5. package/cordis.patch.yml +19 -0
  6. package/docs/2026-10-20-dsh-notify-design.md +335 -0
  7. package/docs/2026-10-20-final-status.md +125 -0
  8. package/docs/2026-10-23-asset-policy.md +170 -0
  9. package/docs/2026-10-23-deployment-strategy-review.md +530 -0
  10. package/docs/2026-10-23-headless-nonintrusive-ui-architecture.md +579 -0
  11. package/docs/2026-10-23-hybrid-dual-mode-design.md +955 -0
  12. package/docs/2026-10-23-timezone-global-report.md +204 -0
  13. package/docs/2026-10-23-web-notify-plugin-spec.md +454 -0
  14. package/docs/2026-10-23-web-plugin-mount.md +111 -0
  15. package/docs/2026-10-23-windows-widget-poc-report.md +312 -0
  16. package/docs/2026-10-24-linux-desktop-verification.md +134 -0
  17. package/docs/BOOT-CARD.md +176 -0
  18. package/docs/RECOVERY-NOTES.md +111 -0
  19. package/docs/WORK-PROTOCOL.md +155 -0
  20. package/docs/deepseek-peak-spec.md +370 -0
  21. package/docs/dsh-harness-notify-spec.md +149 -0
  22. package/icon.svg +6 -0
  23. package/lib/client.js +2175 -0
  24. package/lib/client.src.js +1966 -0
  25. package/lib/index.js +13 -0
  26. package/lib/locale-table.js +215 -0
  27. package/locale/en.json +104 -0
  28. package/locale/ko.json +104 -0
  29. package/market/icons/dsh-notify.svg +6 -0
  30. package/market/index.json +77 -0
  31. package/package.json +80 -4
  32. package/scripts/build-client.mjs +41 -0
  33. package/scripts/gen-locale.mjs +36 -0
  34. package/test/client.test.mjs +601 -0
  35. package/test/locale.test.mjs +114 -0
  36. package/test/manifest.test.mjs +115 -0
  37. package/test/peak.test.mjs +370 -0
  38. package/test/typography.test.mjs +85 -0
@@ -0,0 +1,204 @@
1
+ # 글로벌 타임존·중국 공휴일 실측 보고서
2
+
3
+ <!-- skeleton:
4
+ version: 1.0
5
+ kind: measurement
6
+ status: historical
7
+ updated: 2026-10-23
8
+ summary: Measured global timezone and China public-holiday behaviour behind the DeepSeek peak/off-peak indicator.
9
+ -->
10
+
11
+
12
+ > 상태: active · 최종 갱신: 2026-10-23 · git: main · 상위: [deepseek-peak-spec.md](./deepseek-peak-spec.md)
13
+
14
+ - 대상: `providers/deepseek-peak.ps1`, `ui/presenter.ps1`, `config.ps1`
15
+ - 검증일: 2026-10-23 (Asia/Seoul)
16
+ - 공식 기준: [DeepSeek Models & Pricing](https://api-docs.deepseek.com/quick_start/pricing) (2026-10-23 fetch)
17
+
18
+ ## 인덱스
19
+
20
+ | § | 섹션 | 한 줄 요약 |
21
+ |---|---|---|
22
+ | 1 | 결론 | UTC 절대 판정, 베이징 날짜 보정, DST 무관 카운트다운 |
23
+ | 2 | 공식 스케줄 | 2026 공식 문서와 사용자 전제의 차이 |
24
+ | 3 | 코드 분석 | 공휴일 날짜·10일 TTL·카운트다운·로컬 표기 경로 |
25
+ | 4 | 3개 시간대 매트릭스 | 서울·런던·뉴욕 44개 실측 |
26
+ | 5 | 발견·수정 | 대체근무일 8시간 경계 오차와 UTC TTL |
27
+ | 6 | 잔여 리스크 | 연도 경계, 대체근무일 가정, 실제 OS 시간대 |
28
+ | 7 | 재현 | 테스트 명령과 결과 |
29
+
30
+ ---
31
+
32
+ ## 1. 결론
33
+
34
+ - **공휴일 판정은 이제 베이징 달력일(UTC+8) 기준**이다. 이전에는 UTC 날짜를 썼기 때문에
35
+ 베이징 자정에 시작·끝나는 **대체근무일(调休)** 경계에서 8시간 오차가 있었다.
36
+ - **일반 피크/오프피크 판정과 카운트다운은 UTC 절대 시각**으로 계산된다. 사용자 로컬 시각을
37
+ 날짜 키로 쓰거나 KST를 하드코딩하지 않는다.
38
+ - **로컬 표기만 `TimeZoneInfo`**에 위임한다. 런던 BST, 뉴욕 EDT/EST 전환에서도 동일 UTC
39
+ 시각의 상태·다음 전환·남은 초는 서울과 완전히 같다.
40
+ - 사용자 전제의 **“매일 00:30–08:30 UTC+8 오프피크”는 2026 공식 문서와 다르다.** 공식 문서는
41
+ 피크를 `01:00–04:00`, `06:00–10:00 UTC` 평일(중국 공휴일 제외)로 정의한다. 현재 코드/스펙은
42
+ 공식 문서를 따른다.
43
+
44
+ ## 2. 공식 스케줄과 전제 차이
45
+
46
+ 공식 문서 원문(2026-10-23 확인):
47
+
48
+ > Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese
49
+ > public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full.
50
+
51
+ | 구분 | UTC | 베이징(UTC+8) | 비고 |
52
+ |---|---|---|---|
53
+ | 피크 1 | 01:00–04:00 | 09:00–12:00 | 평일, 공휴일 제외 |
54
+ | 피크 2 | 06:00–10:00 | 14:00–18:00 | 평일, 공휴일 제외 |
55
+ | 그 외 | — | — | 주말·중국 공휴일 포함 오프피크 |
56
+
57
+ 따라서 “베이징 00:30–08:30이 매일 오프피크”라는 전제는 현재 공식 스케줄과 맞지 않는다.
58
+ 현재 코드는 공식 스케줄을 구현하고 있으며, 이 보고서의 테스트도 공식 스케줄을 기준으로 한다.
59
+ 만약 00:30–08:30 베이징 창을 의도한 별도 요금제라면 스케줄 자체를 교체해야 하며, 이는
60
+ 이번 타임존 보정과 별개의 제품 결정이다.
61
+
62
+ ## 3. 코드 분석
63
+
64
+ ### 3.1 공휴일 날짜·연도
65
+
66
+ `Get-PeakDecision`은 입력을 UTC로 정규화한 뒤 **UTC+8 달력일**로 원격 캘린더의 `date`와
67
+ 대조한다. `Get-State`도 같은 기준으로 연도 캐시를 고른다.
68
+
69
+ ```powershell
70
+ $utc = ConvertTo-Utc -Value $UtcNow
71
+ $beijing = $utc.AddHours(8)
72
+ $year = $beijing.Year
73
+ $dateKey = $beijing.ToString('yyyy-MM-dd', [cultureinfo]::InvariantCulture)
74
+ ```
75
+
76
+ 피크 시간 창은 공식 문서대로 **UTC 시각**의 `01–04`, `06–10`을 쓴다. 즉 “날짜는 베이징,
77
+ 시간 창은 UTC”가 올바른 조합이다.
78
+
79
+ ### 3.2 10일 캐시 TTL
80
+
81
+ - 캐시 파일 나이: `DateTime.UtcNow - LastWriteTimeUtc`로 계산한다.
82
+ - 재시도 쿨다운: 마지막 시도도 `DateTime.UtcNow`로 기록한다.
83
+ - 이전 구현은 `Get-Date`와 로컬 `LastWriteTime`의 벽시계 차이를 썼다. DST 전환일에는
84
+ TTL이 최대 1시간 일찍/늦게 만료될 수 있었으나, 12시간 오차는 아니었다.
85
+ - 연도 키는 베이징 연도다. UTC 연도와 베이징 연도가 다른 16:00–24:00 UTC 구간은
86
+ 피크 창 밖이라 상태에는 영향이 없지만, 데이터 선택의 의미를 맞추기 위해 보정했다.
87
+
88
+ ### 3.3 카운트다운
89
+
90
+ - `Get-State`: `$Now.ToUniversalTime()`으로 절대 UTC를 만든다.
91
+ - `Format-PeakSpan`: `NextChangeAt(UTC) - UtcNow(UTC)`의 `TimeSpan`.
92
+ - `presenter.ps1`: `ConvertTo-ContractUtc`로 양쪽을 UTC로 맞춘 뒤 초 단위로 내림.
93
+ - KST 01:30, UTC+9 고정값 같은 하드코딩은 없다.
94
+
95
+ ### 3.4 로컬 표기
96
+
97
+ - `Format-RegionWindow`: `TimeZoneInfo.ConvertTimeFromUtc`와 `GetUtcOffset` 사용.
98
+ - `Get-PeakLocalWindow`: `TimeZoneInfo.ConvertTimeFromUtc` 사용.
99
+ - 수동 `AddHours(9)`/`AddHours(-4)` 같은 오프셋 가감은 표시 경로에 없다.
100
+
101
+ ## 4. 3개 시간대 매트릭스
102
+
103
+ `tests/timezone-matrix.ps1`은 같은 UTC 시각을 서울·런던·뉴욕의 로컬 `DateTime`으로 변환한 뒤
104
+ 다시 UTC로 복원해, 다음 네 값을 비교한다.
105
+
106
+ 1. `Get-PeakDecision().IsPeak`
107
+ 2. `Get-PeakNextChange()`의 UTC 틱
108
+ 3. `Format-PeakTimer()`의 `HH:MM:SS`
109
+ 4. `Format-PeakSpan()`의 남은 시간
110
+
111
+ ### 4.1 대표 실측값
112
+
113
+ | UTC | 상태 | 다음 전환(UTC) | 남은 시간 | 서울 | 런던 | 뉴욕 |
114
+ |---|---|---|---|---|---|---|
115
+ | 2026-10-01 01:00 | Off-Peak (국경절) | 10-08 01:00 | 168:00:00 | 10:00 | 02:00 BST | 09-30 21:00 EDT |
116
+ | 2026-10-08 01:00 | Peak | 10-08 04:00 | 03:00:00 | 10:00 | 02:00 BST | 10-07 21:00 EDT |
117
+ | 2026-10-09 16:00 | Peak (대체근무일) | 10-10 16:00 | 24:00:00 | 10-10 01:00 | 17:00 BST | 10-09 12:00 EDT |
118
+ | 2026-10-10 16:00 | Off-Peak (주말) | 10-12 01:00 | 33:00:00 | 10-11 01:00 | 17:00 BST | 10-10 12:00 EDT |
119
+ | 2026-10-20 10:00 | Off-Peak | 10-21 01:00 | 15:00:00 | 19:00 | 11:00 BST | 06:00 EDT |
120
+
121
+ 모든 행에서 세 시간대의 상태·다음 전환·타이머 문자열이 **완전히 동일**했다.
122
+ (테이블의 런던/뉴욕 로컬 값은 `TimeZoneInfo` 변환 결과이며, DST 오프셋은 아래와 같다.)
123
+
124
+ ### 4.2 DST 오프셋
125
+
126
+ | UTC | 런던 | 뉴욕 | 서울 |
127
+ |---|---:|---:|---:|
128
+ | 2026-01-15 12:00 | +0 | −5 | +9 |
129
+ | 2026-03-29 00:59:59 | +0 | −4 | +9 |
130
+ | 2026-03-29 01:00 | +1 (BST) | −4 | +9 |
131
+ | 2026-07-15 12:00 | +1 | −4 (EDT) | +9 |
132
+ | 2026-10-25 00:59:59 | +1 | −4 | +9 |
133
+ | 2026-10-25 01:00 | +0 | −4 | +9 |
134
+ | 2026-11-01 05:59:59 | +0 | −4 | +9 |
135
+ | 2026-11-01 06:00 | +0 | −5 (EST) | +9 |
136
+
137
+ ### 4.3 테스트 결과
138
+
139
+ ```
140
+ timezone-matrix: 44 passed / 0 failed
141
+ ```
142
+
143
+ ## 5. 발견·수정
144
+
145
+ ### 5.1 대체근무일 8시간 경계 오차 (수정)
146
+
147
+ 수정 전에는 공휴일 날짜를 **UTC 날짜**로 찾았다. 국경절(휴일) 자체는 피크 창 밖이라 상태가
148
+ 우연히 같았지만, 2026-10-10(토) 대체근무일은 **종일 피크**로 처리되므로 경계가 어긋났다.
149
+
150
+ | 시각(UTC) | 베이징 | 수정 전 | 수정 후 |
151
+ |---|---|---|---|
152
+ | 2026-10-09 15:59:59 | 10-09 23:59:59 | Off-Peak | Off-Peak |
153
+ | 2026-10-09 16:00 | 10-10 00:00 | Off-Peak (8시간 늦음) | **Peak** |
154
+ | 2026-10-10 15:59:59 | 10-10 23:59:59 | Peak | **Peak** |
155
+ | 2026-10-10 16:00 | 10-11 00:00 | Peak (8시간 늦게 끝남) | **Off-Peak** |
156
+
157
+ 2026-09-28~10-12를 1분 단위(20,160분)로 전수 비교한 결과:
158
+
159
+ | 버전 | 상태 불일치 |
160
+ |---|---:|
161
+ | 수정 전 | **960분** (대체근무일 시작 8h + 종료 8h) |
162
+ | 수정 후 | **0분** |
163
+
164
+ ### 5.2 10일 TTL의 로컬 벽시계 (수정)
165
+
166
+ `Get-HolidayData`의 `Get-Date - LastWriteTime`을 `DateTime.UtcNow - LastWriteTimeUtc`로 바꿨다.
167
+ 재시도 쿨다운도 UTC로 통일했다. DST 전환일의 1시간 오차를 제거했다.
168
+
169
+ ### 5.3 12시간 오차 주장에 대한 판정
170
+
171
+ 현재 코드는 공휴일 날짜에 로컬 시각(예: 뉴욕 `DateTime.Now`)을 쓴 적이 없다. 따라서
172
+ “뉴욕 사용자가 12시간 늦게 적용”되는 경로는 **없다.** 문제는 로컬 시각이 아니라 **UTC 날짜**를
173
+ 썼다는 점이었고, 그 영향은 대체근무일 경계의 **8시간**이었다.
174
+
175
+ ## 6. 잔여 리스크
176
+
177
+ 1. **연도 경계 데이터**: `Get-State`는 베이징 연도 캐시를 하나만 읽는다. 12월 31일 밤에서
178
+ 1월 1일로 넘어가는 8일 스캔에서 다음 해 공휴일 데이터가 없으면 `estimated`로 강등된다.
179
+ 2027년 공고 전에는 이 한계가 남는다.
180
+ 2. **대체근무일 가정**: `treatMakeupAsPeak`는 공식 문서에 명시되지 않은 제품 가정이다.
181
+ 기본값 `true`는 “전국 의무 근무일은 피크”라는 해석이며, 실제 서버 과금과 다를 수 있다.
182
+ 3. **실제 OS 시간대**: 테스트는 `TimeZoneInfo` 변환 경계를 모킹했다. 실제 Windows에서
183
+ `Get-Date`가 반환하는 `Kind=Local`과 `ToUniversalTime()`의 OS 처리는 별도 스모크에서
184
+ 확인해야 한다. 코드는 수동 오프셋을 쓰지 않고 .NET에 위임한다.
185
+ 4. **공식 스케줄 변경**: 이 보고서는 2026-10-23 공식 문서를 기준으로 한다. 요금제가 바뀌면
186
+ UTC 피크 창과 테스트 픽스처를 함께 갱신해야 한다.
187
+
188
+ ## 7. 재현
189
+
190
+ ```powershell
191
+ pwsh -NoProfile -File tests\timezone-matrix.ps1
192
+ pwsh -NoProfile -File tests\peak-decisions.ps1
193
+ pwsh -NoProfile -File tests\run-all.ps1
194
+ pwsh -NoProfile -File tests\docs-contract.ps1
195
+ ```
196
+
197
+ 실측 결과:
198
+
199
+ | 명령 | 결과 |
200
+ |---|---|
201
+ | `tests\timezone-matrix.ps1` | 44 passed / 0 failed |
202
+ | `tests\peak-decisions.ps1` | 23 passed / 0 failed |
203
+ | `tests\run-all.ps1` | 23 files, 23 passed, 0 failed |
204
+ | `tests\docs-contract.ps1` | 58 files PASS |
@@ -0,0 +1,454 @@
1
+ # DSH 웹 알림 플러그인 — 단독 구현 사양
2
+
3
+ <!-- skeleton:
4
+ version: 1.0
5
+ kind: spec
6
+ status: reference
7
+ updated: 2026-10-23
8
+ summary: Standalone implementation spec for the DSH web notification plugin: surfaces, permissions, and delivery rules.
9
+ -->
10
+
11
+
12
+ > 상태: proposed · 최종 갱신: 2026-10-23 · git: main @ 70a6606 · 상위: [README.md](../README.md)
13
+
14
+ - 전환 결정: 윈도우 상주 앱과 DSH 웹 플러그인은 **서로 완전히 독립된 2개 패키지**다.
15
+ - 이 문서는 **웹 플러그인 단독 사양**만 다룬다. 로컬 IPC/HTTP/파일 큐/포트/토큰/브리지 설계는 폐기한다.
16
+ - 윈도우 앱은 DSH 연동 없이 부팅 시 단독 실행되어 **오프피크 50% 타이머 위젯**만 표시한다.
17
+ - 웹 플러그인은 **Web Notification + Web Audio**만 사용한다. 별도 로컬 서버를 띄우지 않는다.
18
+ - 이번 단계에서 코드와 기존 파일은 수정하지 않았다.
19
+
20
+ ## 인덱스
21
+
22
+ | § | 섹션 | 한 줄 요약 |
23
+ |---|---|---|
24
+ | 1 | 아키텍처 결정 | 2개 독립 패키지, 통신 계층 없음 |
25
+ | 2 | 패키지 구조·설치 | `@nolit/dsh-notify`, web 프로필, no-op host |
26
+ | 3 | DSH 이벤트 계약 | 완료·승인·질문·오류 4종만 |
27
+ | 4 | 알림·오디오 | Notification, Web Audio, 권한 |
28
+ | 5 | 중복·다중 탭 | Web Locks 리더 + 쿨다운 |
29
+ | 6 | UI·설정 | 상태 배지, 인페이지 토스트, config |
30
+ | 7 | 라이프사이클·오류 | 로드/해제, 권한 거부, DSH 재시작 |
31
+ | 8 | 보안·프라이버시 | 네트워크/파일/민감정보 없음 |
32
+ | 9 | 테스트·수용 기준 | 단위/수동/회귀 매트릭스 |
33
+ | 10 | 로드맵·한계 | 2–3.5일, 3단계 |
34
+
35
+ ---
36
+
37
+ ## 1. 아키텍처 결정
38
+
39
+ ```
40
+ [DSH 웹 플러그인] [DSH Notify 윈도우 앱]
41
+ 브라우저 API만 사용 PowerShell/WPF 타이머 위젯
42
+ Web Notification + Web Audio 카드·트레이·오프피크 타이머
43
+ DSH 이벤트 구독(ctx.remote) DSH 이벤트 구독 없음
44
+ │ │
45
+ └────────── 서로 통신하지 않음 ────────────┘
46
+ ```
47
+
48
+ | 결정 | 내용 |
49
+ |---|---|
50
+ | 패키지 분리 | 웹 플러그인과 윈도우 앱은 각자 독립 배포·버전·설정. 상호 import/의존 없음 |
51
+ | 통신 제거 | 로컬 HTTP 서버, 포트 탐색, `runtime.json`, 토큰, named pipe, 파일 큐, 프로세스 스폰 전부 없음 |
52
+ | 웹 플러그인 역할 | DSH 이벤트를 받아 **브라우저 알림과 브라우저 소리**만 낸다 |
53
+ | 윈도우 앱 역할 | DSH와 무관하게 **타이머/카드/트레이**만 표시한다 |
54
+ | 실패 격리 | 웹 플러그인이 없거나 실패해도 앱은 정상. 앱이 없어도 웹 플러그인은 정상 |
55
+
56
+ 웹 플러그인의 host entry는 DSH Loader에 패키지를 등록하기 위한 **no-op**이다. 파일·포트·네트워크·프로세스 코드를 포함하지 않는다.
57
+
58
+ ---
59
+
60
+ ## 2. 패키지 구조·설치
61
+
62
+ ```
63
+ plugin/
64
+ package.json
65
+ cordis.patch.yml
66
+ lib/
67
+ index.js # no-op host entry (Loader 등록용)
68
+ client.js # 브라우저 알림 본체 (module-loader CJS factory)
69
+ README.md
70
+ ```
71
+
72
+ `package.json`:
73
+
74
+ ```json
75
+ {
76
+ "name": "@nolit/dsh-notify",
77
+ "version": "0.1.0",
78
+ "license": "MIT",
79
+ "type": "module",
80
+ "main": "lib/index.js",
81
+ "exports": {
82
+ ".": "./lib/index.js",
83
+ "./client": "./lib/client.js",
84
+ "./package.json": "./package.json"
85
+ },
86
+ "dsh": {
87
+ "manifestVersion": 1,
88
+ "bundle": { "patch": "./cordis.patch.yml" },
89
+ "client": {
90
+ "platform": "web",
91
+ "immediately": true,
92
+ "inject": [
93
+ "@deepseek-ai/dsh-api-remotes",
94
+ "@deepseek-ai/dsh-client-ui-session",
95
+ "@deepseek-ai/dsh-client-ui-slots"
96
+ ]
97
+ }
98
+ },
99
+ "engines": { "node": ">=24", "dsh": ">=0.2.1-rc.2 <0.3.0" },
100
+ "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" }
101
+ }
102
+ ```
103
+
104
+ `cordis.patch.yml`:
105
+
106
+ ```yaml
107
+ - insert:
108
+ - id: dsh-notify-web
109
+ name: '@nolit/dsh-notify'
110
+ config:
111
+ notifyOnComplete: true
112
+ notifyOnApproval: true
113
+ notifyOnQuestion: true
114
+ notifyOnError: true
115
+ completeCooldownMs: 5000
116
+ soundEnabled: true
117
+ volume: 0.3
118
+ suppressWhenFocused: false
119
+ requireInteractionForApproval: true
120
+ notifyOnSubagents: false
121
+ ```
122
+
123
+ `lib/index.js` — 통신 없는 no-op:
124
+
125
+ ```js
126
+ export const name = 'dsh-notify-web'
127
+ export function apply() {}
128
+ ```
129
+
130
+ `lib/client.js` — 뼈대:
131
+
132
+ ```js
133
+ window.__ModuleLoader__.load({
134
+ id: '@nolit/dsh-notify',
135
+ factory: (require) => {
136
+ const React = require('react')
137
+ const exports = {}
138
+ exports.inject = ['remote', 'sessions', 'slots', 'locale']
139
+ exports.apply = (ctx) => {
140
+ // ctx.remote.$on(...) 4종 구독
141
+ // ctx.slots.register(...) 배지/토스트/패널
142
+ }
143
+ return exports
144
+ },
145
+ })
146
+ ```
147
+
148
+ **설치:** web 프로필의 `dsh.profile.bundles`에 `@nolit/dsh-notify`을 추가하고, 프로필 `node_modules/@nolit/dsh-notify`을 이 폴더에 링크한다. `dsh plugin --profile web add link:<절대경로>`로 의존성을 추가한 뒤 번들 목록에 등록한다. desktop 브리지(`@local/dsh-notify-bridge`)는 이 패키지와 무관하며, 검증 후 별도로 제거한다.
149
+
150
+ **에셋 정책:** 이 패키지는 폰트/로고/사운드 파일을 번들하지 않는다. 기본 아이콘은 중립 시계 SVG(MIT)이며, 알림에는 아이콘을 지정하지 않는다. 사용자 주입 에셋의 책임 범위는 [에셋·상표 라이선스 정책](2026-10-23-asset-policy.md)을 따른다.
151
+
152
+ **빌드:** v1은 손으로 작성한 `lib/client.js`를 쓴다. 별도 번들러가 없어 `lib/client.js`가 없으면 로드가 실패하므로 파일 존재를 테스트로 확인한다. 수정 후에는 페이지 새로고침(또는 DSH 재시작)을 기본으로 한다.
153
+
154
+ ---
155
+
156
+ ## 3. DSH 이벤트 계약
157
+
158
+ 브라우저로 전달되는 이벤트만 사용한다. DSH 0.2.1-rc.2 설치본의 포워딩 허용 목록 기준.
159
+
160
+ | 이벤트 | 시그니처 | 사용 | 처리 |
161
+ |---|---|---|---|
162
+ | `api-session/status` | `(sessionId, running)` | 완료 | `running === false`이고 루트 세션이면 알림. 세션별 5초 쿨다운 |
163
+ | `api-session/error` | `(sessionId, message)` | 오류 | 루트 세션이면 알림. 메시지 160자 절단 |
164
+ | `approval/request` | `(request, next)` | 승인 | 즉시 알림 후 `next()` 통과. `next()`는 정확히 1회 |
165
+ | `user-questions/request` | `(request, next)` | 질문 | 즉시 알림 후 `next()` 통과. `next()`는 정확히 1회 |
166
+
167
+ **사용 금지:**
168
+
169
+ - `agent/turn-stopping` — serial(awaited) 이벤트라 턴 종료를 지연시킬 수 있다.
170
+ - `agent/error` — 브라우저 포워딩 허용 목록에 없다. `api-session/error`를 쓴다.
171
+ - `agent/status` — 브라우저 포워딩 허용 목록에 없다. 완료는 `api-session/status`로 판정한다.
172
+
173
+ **루트 세션 필터:**
174
+
175
+ ```js
176
+ const row = ctx.sessions.list.getSnapshot().byId[sessionId]
177
+ const isRoot = !row || (!row.parentId && row.origin !== 'subagent')
178
+ ```
179
+
180
+ 행이 아직 목록에 없으면 알림을 잃지 않기 위해 루트로 간주한다. `notifyOnSubagents`가 true면 필터를 끈다(기본 false). 승인/질문은 서브에이전트 것도 사람이 답해야 하므로 기본적으로 알린다.
181
+
182
+ **승인/질문 지연 금지:**
183
+
184
+ ```js
185
+ ctx.remote.$on('approval/request', function (request, next) {
186
+ try { notifyApproval(this, request) } catch {}
187
+ return next()
188
+ })
189
+ ```
190
+
191
+ `notifyApproval`은 `async`로 만들지 않거나, 만들어도 `await`하지 않는다. 네트워크/권한 요청이 승인 UI를 막으면 안 된다.
192
+
193
+ **완료 판정:**
194
+
195
+ ```js
196
+ const last = new Map() // sessionId -> timestamp
197
+ ctx.remote.$on('api-session/status', (sessionId, running) => {
198
+ if (running) return
199
+ if (!enabled('complete')) return
200
+ if (!isRoot(sessionId)) return
201
+ const now = Date.now()
202
+ if (now - (last.get(sessionId) ?? 0) < config.completeCooldownMs) return
203
+ last.set(sessionId, now)
204
+ notify('complete', sessionId)
205
+ })
206
+ ```
207
+
208
+ ---
209
+
210
+ ## 4. 알림·오디오
211
+
212
+ ### 4.1 Web Notification
213
+
214
+ - `Notification.permission === 'granted'`일 때만 `new Notification(...)`을 호출한다.
215
+ - `default`일 때 자동 요청하지 않는다. 브라우저가 사용자 제스처를 요구하며, 무단 프롬프트는 UX를 해친다. 상태 패널의 **“브라우저 알림 켜기”** 클릭에서만 `requestPermission()`을 호출한다.
216
+ - `denied`이면 인페이지 토스트로 폴백하고 배지에 “알림 차단됨”을 표시한다.
217
+
218
+ ```js
219
+ new Notification(title, {
220
+ body,
221
+ tag: `dsh-notify:${sessionId}:${kind}`, // 같은 세션/종류는 OS가 합침
222
+ silent: true, // 소리는 Web Audio가 담당
223
+ requireInteraction: kind === 'approval' || kind === 'question',
224
+ })
225
+ ```
226
+
227
+ - `tag`로 같은 세션의 연속 완료 알림을 OS 수준에서 합친다.
228
+ - 클릭 시 `window.focus()` 후 `notification.close()`. 세션 딥링크는 v1에서 하지 않는다.
229
+ - 본문은 고정 문구 + 세션 제목 80자. 프롬프트/코드/도구 인자는 넣지 않는다.
230
+
231
+ | kind | title | body |
232
+ |---|---|---|
233
+ | complete | `DSH 작업 완료` | `세션 작업이 끝났습니다. 결과를 확인하세요.` |
234
+ | approval | `DSH 승인 필요` | `도구 실행 승인을 기다리고 있습니다.` |
235
+ | question | `DSH 질문 대기` | `모델이 답변을 기다리고 있습니다.` |
236
+ | error | `DSH 오류` | `모델 요청 또는 도구 실행에서 오류가 발생했습니다.` |
237
+
238
+ ### 4.2 Web Audio
239
+
240
+ - 첫 사용자 제스처(패널의 “소리 켜기” 또는 “테스트 알림”)에서 `AudioContext`를 만들고 `resume()`한다.
241
+ - 알림음은 에셋 없이 합성한다. 예: 880Hz 120ms → 1175Hz 120ms, gain 0.3, exponential ramp.
242
+ - `soundEnabled`가 false면 재생하지 않는다.
243
+ - `AudioContext`가 `suspended`면 재생을 시도하되, 실패해도 알림 자체는 막지 않는다.
244
+ - 페이지가 백그라운드여도 오디오는 재생될 수 있다. 브라우저가 정책상 막으면 무음이 되며, 배지에 “소리 잠김”을 표시한다.
245
+ - 알림음은 실제 알림이 표시될 때 1회만 재생한다. 인페이지 토스트만 뜨는 경우에도 1회 재생한다.
246
+
247
+ ### 4.3 포커스/가시성
248
+
249
+ - `suppressWhenFocused`가 true이고 `document.hasFocus()`가 true이면 완료 알림은 인페이지 토스트만 띄운다(승인/질문은 항상 Notification).
250
+ - 기본값 false: 다른 창을 보고 있어도 OS 알림을 띄운다.
251
+ - `pageVisible`/`pageFocused`는 로그/디버그에만 쓰고 알림 본문에는 넣지 않는다.
252
+
253
+ ---
254
+
255
+ ## 5. 중복·다중 탭
256
+
257
+ 같은 DSH 페이지를 여러 탭에 열면 각 탭이 같은 이벤트를 받아 알림이 중복된다.
258
+
259
+ 1. **Web Locks 리더**
260
+ ```js
261
+ navigator.locks.request('dsh-notify-leader', { mode: 'exclusive' }, async () => {
262
+ leader = true
263
+ await new Promise(() => {}) // 탭 종료까지 유지
264
+ })
265
+ ```
266
+ 리더만 Notification/오디오를 낸다. 탭이 닫히면 락이 풀리고 다른 탭이 승계한다.
267
+ 2. **폴백 리더**: Web Locks가 없거나 보안 컨텍스트가 아니면 `localStorage` 리스(10초 TTL, 3초 갱신)로 선출한다. `storage` 이벤트로 리더 변경을 감지한다.
268
+ 3. **이벤트 id**: `crypto.randomUUID()`(없으면 `sessionId-kind-timestamp-random`). 서버가 없으므로 앱 멱등성은 필요 없다.
269
+ 4. **쿨다운**: 같은 세션 완료 5초. `running=true` 수신 시 쿨다운을 리셋한다.
270
+ 5. **Notification tag**: 같은 세션/종류의 알림을 OS가 합치도록 한다.
271
+
272
+ 리더가 아니어도 상태 배지는 각 탭에서 갱신한다. 리더 변경 시 배지 툴팁에 “이 탭이 알림 담당”을 표시한다.
273
+
274
+ ---
275
+
276
+ ## 6. UI·설정
277
+
278
+ ### 6.1 상태 배지
279
+
280
+ `sidebar.footer.action` 슬롯에 작은 점/배지를 등록한다.
281
+
282
+ | 상태 | 표시 | 클릭 시 |
283
+ |---|---|---|
284
+ | 권한 granted + 오디오 언락 | 녹색 점 | 상태 패널 |
285
+ | 권한 default (미요청) | 주황 점 | 상태 패널 + “알림 켜기” 강조 |
286
+ | 권한 denied | 회색 점 + `!` | 상태 패널(차단 안내) |
287
+ | Notification 미지원 | 회색 점 | 인페이지 토스트만 사용 안내 |
288
+
289
+ ### 6.2 상태 패널
290
+
291
+ `shell.overlay` 또는 배지 클릭 팝업으로 다음을 제공한다.
292
+
293
+ - 현재 권한/오디오 상태.
294
+ - **브라우저 알림 켜기** — `Notification.requestPermission()`.
295
+ - **소리 켜기** — `AudioContext` 생성/`resume()`.
296
+ - **테스트 알림** — `complete` 알림 1회.
297
+ - 종류별 토글(완료/승인/질문/오류)과 소리 on/off.
298
+ - “이 탭이 알림 담당” 표시(리더 여부).
299
+
300
+ 설정은 `localStorage`에 저장한다(키: `dsh-notify-web:settings`). DSH 프로필 config는 초기 기본값으로만 사용하고, 사용자 토글은 브라우저에 저장한다. 서버/파일 저장은 하지 않는다.
301
+
302
+ ### 6.3 인페이지 토스트
303
+
304
+ - `shell.overlay`에 6초 자동 닫힘 토스트를 등록한다.
305
+ - Notification이 거부/미지원일 때, 또는 `suppressWhenFocused`로 OS 알림을 생략했을 때 사용한다.
306
+ - 제목/본문/닫기 버튼만. 클릭하면 토스트를 닫고 `window.focus()`.
307
+ - 최대 3개까지 스택, 같은 `tag`는 교체.
308
+
309
+ ### 6.4 설정 스키마
310
+
311
+ ```json
312
+ {
313
+ "notifyOnComplete": true,
314
+ "notifyOnApproval": true,
315
+ "notifyOnQuestion": true,
316
+ "notifyOnError": true,
317
+ "completeCooldownMs": 5000,
318
+ "soundEnabled": true,
319
+ "volume": 0.3,
320
+ "suppressWhenFocused": false,
321
+ "requireInteractionForApproval": true,
322
+ "notifyOnSubagents": false
323
+ }
324
+ ```
325
+
326
+ 경로·포트·토큰·앱 관련 키는 **없다.**
327
+
328
+ ---
329
+
330
+ ## 7. 라이프사이클·오류
331
+
332
+ ```
333
+ 플러그인 로드
334
+ ├─ 권한/설정 로드(localStorage + config 기본값)
335
+ ├─ ctx.remote.$on 4종 등록
336
+ ├─ 슬롯(배지/토스트/패널) 등록
337
+ └─ Web Locks 리더 시도
338
+
339
+ 플러그인 해제
340
+ ├─ 이벤트 구독 해제
341
+ ├─ 슬롯 해제
342
+ ├─ 리더 락 해제(탭 종료 시 자동)
343
+ └─ AudioContext close
344
+ ```
345
+
346
+ | 상황 | 동작 |
347
+ |---|---|
348
+ | 권한 default | 자동 요청하지 않음. 배지 주황, 패널에서 요청 |
349
+ | 권한 denied | Notification 생략, 인페이지 토스트. 배지 회색 |
350
+ | 오디오 잠김 | 알림은 정상, 소리만 생략. 배지 툴팁 안내 |
351
+ | Notification 미지원 | 인페이지 토스트만 |
352
+ | DSH 재시작 | 페이지가 새로 로드되며 플러그인 재적용. 상태는 localStorage에서 복원 |
353
+ | DSH 포트 변경 | 새 출처가 되어 권한/오디오를 다시 받아야 함. 코드 수정은 없음 |
354
+ | 탭 닫힘 | 그 탭의 리더/알림 종료. 다른 탭이 있으면 승계, 없으면 알림 없음 |
355
+ | 이벤트 핸들러 예외 | `try/catch`로 삼키고 `ctx.logger.warn` 1회. DSH 턴에 영향 없음 |
356
+ | 승인/질문 | 알림 실패와 무관하게 `next()` 통과 |
357
+
358
+ **로깅:** `ctx.logger.debug/info/warn`만 사용한다. 파일 로그를 만들지 않는다. 민감정보(프롬프트/코드/토큰)는 로그에도 남기지 않는다.
359
+
360
+ ---
361
+
362
+ ## 8. 보안·프라이버시
363
+
364
+ - 네트워크 요청 없음: `fetch`, `XMLHttpRequest`, WebSocket, `EventSource`를 새로 만들지 않는다. DSH의 기존 Remote 채널만 쓴다.
365
+ - 파일 시스템 접근 없음: `%LOCALAPPDATA%`, `runtime.json`, 로그 파일을 읽지 않는다.
366
+ - 로컬 서버/포트 없음: 리스너를 열지 않는다.
367
+ - 알림 내용: 고정 문구 + 세션 제목 80자. 프롬프트 원문/도구 인자/코드/첨부 파일명은 넣지 않는다.
368
+ - OS 알림은 잠금 화면에 보일 수 있음을 문서화한다. 민감한 세션 제목은 사용자가 설정에서 끌 수 있다(`showSessionTitle` 옵션은 v1.1 후보).
369
+ - 권한은 사용자 제스처로만 요청한다. 자동 프롬프트/반복 프롬프트 금지.
370
+ - 외부 링크/URL을 열지 않는다. 클릭은 `window.focus()`만.
371
+
372
+ ---
373
+
374
+ ## 9. 테스트·수용 기준
375
+
376
+ ### 단위 테스트(모의 객체)
377
+
378
+ | ID | 케이스 | 기대 |
379
+ |---|---|---|
380
+ | U1 | `api-session/status(false)` 루트 | 알림 1회 |
381
+ | U2 | 같은 세션 5초 내 재수신 | 추가 알림 없음 |
382
+ | U3 | `running=true` 후 다시 false | 쿨다운 리셋, 알림 1회 |
383
+ | U4 | 서브에이전트(`parentId`/`origin`) | 알림 없음 |
384
+ | U5 | `approval/request` | 알림 + `next()` 1회, 지연 없음 |
385
+ | U6 | `user-questions/request` | 알림 + `next()` 1회 |
386
+ | U7 | `api-session/error` | 오류 알림, 160자 절단 |
387
+ | U8 | 권한 denied | Notification 미호출, 토스트 호출 |
388
+ | U9 | 다중 탭(리더 2개 시뮬레이션) | 리더만 알림 |
389
+ | U10 | 오디오 잠김 | 알림은 정상, 예외 없음 |
390
+
391
+ ### 수동 E2E
392
+
393
+ 1. DSH web 실행, 플러그인 로드.
394
+ 2. 패널에서 알림/소리 켜기 → 테스트 알림.
395
+ 3. 턴 완료 → OS 알림 1회 + 소리 1회.
396
+ 4. 승인 대기 → 알림 + 승인 UI 정상, 턴 지연 없음.
397
+ 5. 탭 2개 → 알림 1회.
398
+ 6. 권한 거부 → 인페이지 토스트.
399
+ 7. DSH를 다른 포트로 재실행 → 코드 수정 없이 동작, 권한 재요청 UX.
400
+ 8. 앱(윈도우 위젯) 종료/실행과 무관하게 웹 알림 동작.
401
+
402
+ ### 회귀
403
+
404
+ - `pwsh -NoProfile -File tests\run-all.ps1` — 기존 19/19 유지(앱 변경 없음).
405
+ - `pwsh -NoProfile -File tests\docs-contract.ps1` — 새 문서 포함 통과.
406
+ - 웹 플러그인은 로컬 파일/포트를 만들지 않으므로 기존 앱 테스트에 영향 없음.
407
+
408
+ ### 수용 기준
409
+
410
+ | ID | 기준 |
411
+ |---|---|
412
+ | A1 | DSH 탭이 열려 있으면 완료/승인/질문/오류 알림이 브라우저 API만으로 동작 |
413
+ | A2 | 로컬 앱이 없어도, 있어도 웹 플러그인 동작이 동일 |
414
+ | A3 | 어떤 로컬 포트/HTTP/파일 IPC도 사용하지 않음(코드 검색으로 확인) |
415
+ | A4 | 승인/질문 `next()` 지연 < 50ms |
416
+ | A5 | 다중 탭에서 알림 1회 |
417
+ | A6 | 권한 거부 시 인페이지 토스트 폴백 |
418
+ | A7 | DSH 포트 변경 시 코드/설정 수정 없이 동작(권한 재승인 제외) |
419
+ | A8 | 알림 본문에 프롬프트/코드/토큰 없음 |
420
+ | A9 | DSH 탭 닫힘 시 알림 없음(한계로 문서화) |
421
+
422
+ ---
423
+
424
+ ## 10. 로드맵·한계
425
+
426
+ | Phase | 내용 | 산출물 | 예상 |
427
+ |---|---|---|---|
428
+ | 1 | 패키지 골격·이벤트 구독 | `package.json`, `cordis.patch.yml`, no-op host, `client.js` 구독/필터/쿨다운 | 0.5–1일 |
429
+ | 2 | 알림·오디오·권한·UI | Notification/Web Audio, 배지/패널/토스트, 권한 UX | 1–1.5일 |
430
+ | 3 | 다중 탭·하드닝·문서 | Web Locks 리더, 테스트, README, 설치 절차 | 0.5–1일 |
431
+
432
+ **총 2–3.5일.** 앱 변경이 없으므로 기존 19개 테스트는 그대로 기준선이다.
433
+
434
+ | 한계/위험 | 영향 | 대응 |
435
+ |---|---|---|
436
+ | DSH 탭이 닫히면 알림 없음 | 백그라운드 알림 불가 | 설계상 수용. 필요하면 DSH 자체 백그라운드 알림을 별도 검토 |
437
+ | 알림 권한은 출처 단위 | 포트 변경 시 재승인 | 배지/패널에서 명확히 안내, 자동 재프롬프트 금지 |
438
+ | 오디오 자동재생 차단 | 첫 알림 무음 가능 | 첫 제스처 언락, “소리 켜기” 버튼, 무음 허용 |
439
+ | 백그라운드 탭 타이머 스로틀 | 쿨다운/배지 갱신 지연 가능 | 이벤트 기반 처리, `setInterval` 최소화 |
440
+ | DSH 클라이언트 API 변경 | 구독/슬롯 깨짐 | `engines.dsh` 고정, 이벤트 4종 단위 테스트 |
441
+ | 알림 피로 | 사용자가 끔 | 종류별 토글, 세션 쿨다운, 포커스 시 완료 억제 옵션 |
442
+ | OS 알림 잠금 화면 노출 | 프라이버시 | 본문 고정 문구, 세션 제목 표시 옵션(후속) |
443
+ | 다중 탭 리더 경합 | 일시적 중복/누락 | Web Locks 원자성, 폴백 리스, 테스트 |
444
+
445
+ ---
446
+
447
+ ## 부록: 금지 패턴 체크리스트
448
+
449
+ - [ ] `fetch(`, `XMLHttpRequest`, `WebSocket`, `EventSource`로 로컬 서버를 호출하지 않는다.
450
+ - [ ] `runtime.json`, `%LOCALAPPDATA%`, `node:fs`, `node:net`을 쓰지 않는다.
451
+ - [ ] 포트 번호(3080, 47600 등)를 상수로 두지 않는다.
452
+ - [ ] `agent/turn-stopping`, `agent/error`를 구독하지 않는다.
453
+ - [ ] 승인/질문 핸들러에서 `await`로 `next()`를 지연하지 않는다.
454
+ - [ ] 알림 본문에 프롬프트/코드/토큰을 넣지 않는다.