<div align="center">

# dsh-session-notify

[简体中文](README.md) · [English](README.en.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · **한국어**

**DSH（DeepSeek Harness）세션 완료 알림 플러그인 —— 매 턴이 끝날 때 완료 상태가 스스로 여러분을 찾아갑니다. 화면을 지켜볼 필요가 없습니다.**

[![npm version](https://img.shields.io/npm/v/@telosmaylx/dsh-session-notify)](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
[![npm downloads](https://img.shields.io/npm/dm/@telosmaylx/dsh-session-notify)](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
[![license](https://img.shields.io/npm/l/@telosmaylx/dsh-session-notify)](./LICENSE)
[![node](https://img.shields.io/node/v/@telosmaylx/dsh-session-notify)](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
[![DSH](https://img.shields.io/badge/DSH-Web%20Profile-4D6BFE)](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/TelosmaYLX/dsh-session-notify/pulls)

대화의 각 턴이 끝날 때 「완료 / 오류 / 차단 / 상한 도달」 상태를 소요 시간, token 소모량과 함께 세션 로그에 기록하고, 브라우저 시스템 알림과 페이지 내 toast를 푸시합니다(**창 비포커스 / 포커스 상태별로 채널을 따로 지정할 수 있으며, 두 경로 모두 「알림 안 함」 포함**). **AI가 질문하거나 승인을 요청할 때도 즉시 팝업으로 알려드립니다** — 세션 페이지를 지켜볼 필요가 없습니다. 5개 언어, 4가지 스타일 프리셋(카오모지 / 아이루 / 네코무스메 / DeepSeek짱), 시각적 문구 템플릿 편집기, 커스텀 프리셋 라이브러리가 내장되어 있으며, 캐시 적중률과 생성 속도는 공식 프로젝션에서 가져와 상태 표시줄과 동일한 기준을 사용합니다.

</div>

---

## 목차

- [주요 기능](#주요-기능)
- [환경 요구사항](#환경-요구사항)
- [설치](#설치)
- [제거](#제거)
- [빠른 시작](#빠른-시작)
- [알림 동작](#알림-동작)
  - [트리거 조건](#트리거-조건)
  - [알림 본문은 어디서 오나](#알림-본문은-어디서-오나)
  - [알림 예시](#알림-예시)
  - [알림 권한](#알림-권한)
- [설정](#설정)
  - [설정 패널](#설정-패널)
  - [문구 템플릿과 플레이스홀더](#문구-템플릿과-플레이스홀더)
  - [프리셋 시스템](#프리셋-시스템)
  - [호스트 설정 항목](#호스트-설정-항목)
- [동작 원리](#동작-원리)
- [프로젝트 구조](#프로젝트-구조)
- [개발 및 디버깅](#개발-및-디버깅)
- [자주 묻는 질문](#자주-묻는-질문)
- [변경 로그](#변경-로그)
- [감사](#감사)
- [기여](#기여)
- [관련 링크](#관련-링크)
- [라이선스](#라이선스)

---

## 주요 기능

<div align="center">

<img src="screenshot/screenshot1.png" width="220" alt="제목 편집기">
<img src="screenshot/ScreenShot2png.png" width="220" alt="본문 편집기">
<img src="screenshot/ScreenShot3.png" width="220" alt="작업 완료 알림">
<img src="screenshot/ScreenShot4.png" width="220" alt="AI 질문 알림">
<img src="screenshot/ScreenShot5.png" width="220" alt="작업 오류 알림">

</div>

### 3채널 알림, 하나도 빠짐없이

| 채널 | 형태 | 설명 |
| --- | --- | --- |
| 세션 내 시스템 메시지 | 접을 수 있는 알림 행 | 각 턴이 끝날 때 종료 사유와 소요 시간, 소모량을 플러그인 소스의 시스템 메시지로 세션 로그에 추가합니다. JSONL로 디스크에 저장되며 세션을 복원하거나 재생해도 계속 볼 수 있습니다. |
| 브라우저 시스템 알림 | Web Notification | 네이티브 팝업입니다. 각 완료 이벤트는 고유한 `tag`(`dsh-session-notify:<timestamp>`)를 사용하므로 이전 알림과 서로 교체되지 않고 그룹 항목으로 접히지 않습니다. 알림을 클릭하면 창으로 포커스가 돌아옵니다. |
| 페이지 내 toast | 오른쪽 아래 플로팅 팝업 | 항상 표시되는 보험 채널입니다. 시스템 알림이 플랫폼에서 무음 처리되거나 권한이 거부되거나 환경이 지원하지 않아도 시각적 피드백이 남아 있습니다. 같은 화면에 최대 3개(초과 시 가장 오래된 것 제거), 10초 후 자동으로 사라지며 클릭하면 닫힙니다. |

> [!NOTE]
> 뒤의 두 채널은 **창 포커스 상태에 따라 분기됩니다**. 설정 패널에 「비포커스 시」「포커스 시」 드롭다운이 각각 하나씩 있고, 각각 `시스템 + 페이지 내` / `시스템만` / `페이지 내만` / `알림 안 함` 중에서 고를 수 있습니다. 비포커스 판정 조건은 `document.visibilityState === 'hidden'` 또는 `!document.hasFocus()` —— 탭을 전환했거나 창을 최소화했거나 다른 곳을 클릭했을 때 「비포커스 시」 경로가 사용됩니다.

### 백그라운드 세션 완전 지원

- 호스트는 모든 세션(백그라운드, 열려 있지 않은 창 포함)에 대해 「마지막 알림 본문」의 세션 프로젝션 유닛(key = `session-complete-notify`)을 유지합니다. 알림 본문은 세션 간에 일관되며, 사용자가 해당 창을 열어 두고 있지 않아도 됩니다.
- 클라이언트는 세션 목록 스냅샷에서 모든 세션의 `running` 비트를 관찰하며, `true → false` 엣지가 곧 알림 트리거입니다. 공식 sidebar 알림과 동일한 전략입니다(최초 관찰 시에는 기준선만 기록하며, 이미 idle 상태인 세션은 추가 발송하지 않습니다).

### 질문 즉시 알림

- AI가 `ask_user_question`을 호출해 질문하면, 호스트가 즉시 「질문 제목 + 본문」을 전용 프로젝션(key = `session-complete-notify-question`)에 기록하고, 클라이언트가 실시간 폴링 후 팝업으로 알립니다——**다른 페이지를 보고 있어도 질문을 놓치지 않습니다**.
- 질문 문구는 완전히 커스터마이징할 수 있습니다. 제목은 「사유별 제목 → 전역 제목 → 기본 제목」 순서로 결정되며, 본문은 `{question}` 플레이스홀더(AI의 실제 질문 주입)와 `{image}` / `{icon}` 미디어 스위치를 지원합니다.

### 승인 즉시 알림

- 세션이 권한 승인을 요청하는 즉시(`approval/asked`) 알리고 `approval/decided`에서 해제 — 다른 탭을 보고 있어도 승인을 놓치지 않습니다.
- 3중 신호 폴백: harness 네이티브 `pendingInteractions`(호스트가 제공하면 가장 정확) → 호스트 승인 프로젝션(key = `session-complete-notify-approval`, 제목과 본문은 호스트가 현재 언어로 렌더링) → 세션 목록 스냅샷의 `pendingInteraction === 'approval'`.
- 문구에는 도구 이름과 선택적 이유만 담깁니다(예: "세션이 Bash 승인을 기다리고 있습니다."). **명령 인자 등 민감한 내용은 포함하지 않습니다.** 푸시 채널과 미디어 설정도 동일하게 적용됩니다.

### 문장 하나하나까지 커스터마이징

- **5개 언어**: 간체 중국어, 번체 중국어, English, 일본어, 한국어 —— 알림 문구, 소요 시간·소모량 표현, 설정 패널 UI가 모두 언어에 따라 전환됩니다(전환 즉시 재렌더링).
- **시각적 템플릿 편집기**(Chip 캡슐 편집기): 동적 정보를 인라인 캡슐로 렌더링합니다(플레이스홀더 코드가 노출되지 않음). 「+ 정보 삽입」은 커서 위치에 삽입되며(텍스트 중간에도 삽입 가능), 캡슐을 클릭하면 제거됩니다. 각 항목마다 실시간 미리보기가 제공됩니다(정보는 예시 값으로 본문에 흘러 들어감).
- **프리셋 시스템**: 내장 「기본」 기준선 + 원클릭 스타일 프리셋 4종(카오모지 / 아이루 / 네코무스메 / DeepSeek짱 — 제목과 5개 종료 사유 + 질문 문구를 통째로 스타일화). 현재 설정은 커스텀 프리셋으로 별도 저장할 수 있으며(`localStorage` 영속화), 자동 번호가 매겨진 이름 없는 프리셋(`이름 없음`, `이름 없음 2`…)과 「출처: xxx · 수정됨」 출처 표시, 프리셋 삭제를 지원합니다.
- **알림 제목 템플릿**: 비워 두면 각 사유에 대해 기본 제목을 사용합니다(완료=작업 완료 / 오류=작업 오류 / … / 질문=AI가 질문했습니다). `{title}`은 세션 제목을 참조합니다.

### 공식 지표와 동일 기준

- **캐시 적중률**은 공식 `tokenUsage` 프로젝션에서 가져옵니다: 캐시 읽기 /(비캐시 입력 + 캐시 읽기 + 캐시 쓰기).
- **생성 속도**는 공식 `sessionStats` 프로젝션에서 가져옵니다: 출력 token ÷ 디코딩 소요 시간.
- 두 지표 모두 dsh-web-ui 상태 표시줄과 완전히 동일한 기준이며, 대기, 준비, 도구 시간은 포함하지 않습니다. 프로젝션을 사용할 수 없거나 데이터가 준비되지 않으면 로컬 사용량 집계 추정으로 자동 폴백합니다.

> [!NOTE]
> 캐시 적중률과 속도는 커스텀 템플릿에서 `{cache}`, `{tps}` 플레이스홀더로 삽입할 때만 표시됩니다. 내장 기본 문구를 사용할 때는 본문에 소요 시간과 소모량이 포함되지 않습니다(표시하려면 커스텀 템플릿에 플레이스홀더를 삽입하세요).

### 엔지니어링 품질

- **실시간 이벤트에만 응답**: resume, replay 시 이전 알림을 재생하지 않으며, 세션을 불러와도 화면이 넘치지 않습니다.
- **자체 무한 루프 방지**: 플러그인이 추가하는 메시지 유형(`user/message`)과 자체 리스닝 대상(`turn/*`)이 서로 겹치지 않습니다.
- **외부 의존성 제로**: 호스트 플레인에는 bare import가 없으며, UserMessage는 `dsh-llm`의 `createUserMessage` 계약에 따라 수동으로 구성합니다. 순수 로직 레이어(`lib/core.js`)는 의존성이 없어 독립적으로 테스트할 수 있습니다.
- **Cordis effect 규율**: 재시도 타이머를 `ctx.effect()`에 감싸 `clearTimeout` disposer를 반환하며, 등록은 fiber 언마운트 시 자동으로 취소되어 HMR 핫 리로드에도 안전합니다.
- **설치 즉시 마운트**: 공식 `dsh.bundle` manifest를 선언하므로 `dsh plugin add` 한 줄이면 설치 후 바로 사용할 수 있으며, patch를 직접 작성할 필요가 없습니다.

---

## 환경 요구사항

| 의존성 | 요구사항 |
| --- | --- |
| DSH（DeepSeek Harness） | Web profile 배포. 공식 base bundle에는 기본적으로 `@deepseek-ai/dsh-settings`(설정 네임스페이스)와 세션 프로젝션이 포함되어 있어 추가 설정이 필요 없습니다 |
| cordis | `>=4.0.0-rc <5`(peer dependency, 호스트가 제공) |
| Node.js | `>=22`(호스트 측) |
| 브라우저 | Web Notification을 지원하면 시스템 알림 사용 가능. 미지원, 권한 거부, 무음 처리 시 toast가 폴백 |

---

## 설치

> [!WARNING]
> 맨 `npm install`은 패키지를 의존성 트리에 넣을 뿐 **플러그인을 등록하지 않습니다** —— 이는 DSH의 공식 설계입니다(`npm install only adds the dependency; it does not register the plugin`). 자동 마운트의 유일한 공식 경로는 `dsh plugin add`입니다: 패키지 내 `dsh.bundle` manifest(이 플러그인은 0.1.3부터 선언하며, 저장소 루트의 `cordis.patch.yml`을 가리킴)를 읽어 자동 적용합니다.

### 방법 1: dsh plugin add(권장)

패키지 설치와 동시에 `cordis.patch.yml`을 자동 적용하여 플러그인을 profile 어셈블리에 마운트합니다(host 이벤트 구독 + client 시작 그래프 주입).

```bash
dsh plugin --profile web add @telosmaylx/dsh-session-notify
```

### 방법 2: GitHub 저장소에서 설치

```bash
dsh plugin add github:TelosmaYLX/dsh-session-notify
```

DSH Web GUI 세션 안에서도 실행할 수 있습니다:

```bash
dev_install_package github=TelosmaYLX/dsh-session-notify
```

### 방법 3: 로컬 디렉터리 핫 어셈블(개발용)

경로를 사용자의 클론 디렉터리로 바꾼 뒤 DSH Web GUI 세션 안에서 실행합니다:

```bash
dev_install_package dir=/你的/克隆目录/dsh-session-notify
```

### 방법 4: npm 패키지 수동 설치

먼저 패키징합니다:

```bash
npm pack @telosmaylx/dsh-session-notify
```

압축 해제 후 지정 디렉터리에 설치합니다(DSH Web GUI 세션 안에서 실행):

```bash
dev_install_package dir=/解压/目录/package
```

### 방법 5: 수동 cordis patch(설치기 불필요)

`~/.dsh/profiles/web/cordis.patch.yml`에 추가합니다:

```yaml
- insert:
    - id: dsh-session-notify
      name: '@telosmaylx/dsh-session-notify'
      config: {}
```

> [!IMPORTANT]
> 어떤 방법을 쓰든 설치 후에는 **브라우저 페이지를 한 번 새로고침**해야 합니다 —— 클라이언트 bundle은 `__DSH_BOOT__` 시작 그래프를 통해 주입됩니다.

## 제거

한 줄 명령으로 플러그인과 그 마운트를 제거합니다(`cordis.patch.yml`에서 insert 항목을 자동으로 제거):

```bash
dsh plugin --profile web remove @telosmaylx/dsh-session-notify
```

> [!NOTE]
> 수동 설치(방법 4/5) 사용자는 `~/.dsh/profiles/web/cordis.patch.yml`에서 해당 insert 항목을 함께 삭제한 뒤 페이지를 새로고침해야 합니다.

### 제거 시 자동으로 정리되는 항목

플러그인은 완전한 생명주기 마무리를 구현합니다(Cordis effect 규율). 제거/비활성화/HMR 핫 리로드 시:

| 플레인 | 자동 해제되는 리소스 |
| --- | --- |
| host | `session/event` 이벤트 구독, settings 네임스페이스, 세션 프로젝션 유닛, 설정 등록 재시도 타이머(`ctx.effect` 래핑). 언로드 플래그를 설정해 예약된 마이크로태스크 추가를 억제 |
| client | 세션 목록 구독, 완료 알림 본문 폴링 타이머, `window.__dsch_notify_debug` 디버그 훅(참조로 삭제, 클로저 누수 방지), 페이지 내 toast 컨테이너 DOM |

### 제거 후 유지되는 데이터

- **설정 구성**(언어, 문구 템플릿)은 settings 문서에 남아 있어 재설치 후 자동으로 복원됩니다.
- **커스텀 프리셋**은 브라우저 `localStorage`(`dsh-scn-custom-presets`)에 저장되므로 재설치 후에도 유지됩니다.
- 과거 세션에 추가된 시스템 메시지와 JSONL 로그는 **롤백되지 않습니다**(세션 데이터의 일부이며, 공식 사이드바 알림과 동일한 의미).

---

## 빠른 시작

1. 위의 아무 방법으로나 설치하고 페이지를 새로고침합니다.
2. 아무 대화 턴이나 시작하고 끝나기를 기다립니다 —— 오른쪽 아래에 toast가 뜨고, 브라우저에 시스템 알림이 뜨며, 세션 로그에 접을 수 있는 시스템 알림 행이 나타납니다.
3. 처음으로 완료 이벤트를 받으면 브라우저가 알림 권한을 요청합니다(페이지당 한 번만 물어봄). 허용하면 이후 완료마다 시스템 알림이 표시됩니다.
4. **설정 → 플러그인 → 세션 완료 알림**을 열어 언어를 전환하고, 문구 템플릿을 편집하고, 프리셋을 별도로 저장합니다. 저장 후 「클릭하여 새로고침」을 누르면 호스트와 클라이언트 양쪽이 다시 읽어 새 설정이 적용됩니다.

방금 설치한 직후에는 세션 로그에 다음과 같은 접을 수 있는 알림 행이 나타납니다:

```text
会话「重构登录模块」已完成（用时 1 分 12 秒，消耗 1,240 输入 / 3,560 输出）。
```

> 기본 문구는 「세션」 뒤에 세션 제목 라벨(`{title}`)을 내장합니다. 세션에 제목이 없으면 「세션 완료」로 자동 폴백합니다.

---

## 알림 동작

### 트리거 조건

대화의 각 턴이 끝날 때(`turn/end`) 종료 사유를 판단하여 화이트리스트에 포함되면 알림을 보냅니다:

| 종료 사유 | 의미 | 기본값 |
| --- | --- | --- |
| `completed` | 세션 정상 완료 | 알림 |
| `aborted` | 세션 중단 | 알림 |
| `blocked` | 세션 차단됨 | 알림 |
| `error` | 세션 오류(오류 상세 포함, 초과 시 잘림) | 알림 |
| `max-tokens` | 출력 token 상한 도달 | 알림 |
| `interrupted` | 중단(크래시 복구 후 영속화 백엔드가 보완한 고아 턴 종료 표시) | 알림 없음(설정으로 추가 가능) |

**하위 에이전트 세션은 기본적으로 건너뜁니다**(`header.origin === 'subagent'` 또는 `delegationDepth > 0`) —— 하위 에이전트는 상위 세션이 조율하므로 턴마다 알림을 보내면 노이즈가 됩니다. 호스트 설정에서 건너뛰기를 해제할 수 있습니다.

**질문 알림은 독립 채널이며 위 화이트리스트에 포함되지 않습니다**: AI가 `ask_user_question`을 호출해 답변을 기다리는 동안(`tool/call` 이벤트) 즉시 알림이 뜨고, `tool/result`가 반환되면 알림이 무효화됩니다. 질문은 세션 로그에 기록되지 않고 알림만 표시됩니다.

**승인도 독립 채널입니다**: 세션이 권한 승인을 요청하는 즉시(`approval/asked`) 알리고 `approval/decided`에서 해제합니다. 마찬가지로 세션 로그에는 기록하지 않고 알림만 보냅니다. 제목과 본문에는 도구 이름과 선택적 이유만 담기고 명령 인자는 포함되지 않습니다.

### 알림 본문은 어디서 오나

클라이언트는 세션 목록에서 `running: true → false` 엣지를 관찰하면 알림을 보내며, 본문은 다음 우선순위로 가져옵니다(최대 6초 폴링, 400ms 간격):

1. **호스트 프로젝션**(key = `session-complete-notify`) —— 모든 세션에 있으며, 백그라운드 세션도 동일하게 전문을 받습니다.
2. **세션 이벤트 윈도우의 notice 노드**(`kind=context` + `form=notice`) —— 보고 있는 세션은 디스크 저장 직후 바로 사용할 수 있습니다.
3. **폴백** —— 「상세는 세션 내 시스템 메시지 참조」+ 작업 영역 정보(`cwd` 마지막 세그먼트).

질문 알림의 본문도 호스트 프로젝션(key = `session-complete-notify-question`, 호스트가 제목과 본문을 이미 렌더링)을 우선 사용하며, 해당 프로젝션이 없는 구버전 호스트에서는 클라이언트가 제목과 `{question}` 텍스트를 직접 조합해 폴백합니다.

승인 알림은 사용 가능한 순서로 3가지 신호를 조회합니다: harness 네이티브 `pendingInteractions` → 호스트 승인 프로젝션(key = `session-complete-notify-approval`) → 세션 목록 스냅샷의 `pendingInteraction` 필드. 먼저 확보되는 신호로 알리며, 같은 승인은 한 번만 푸시합니다.

### 알림 예시

아래는 모두 `lib/core.js`의 `buildNotice`가 실제로 생성한 것입니다. 기본 문구는 「세션「{title}」〇〇. 클릭하여 확인.」 형식으로 통일됩니다(종료 사유에 따라 단어가 다르며, **소요 시간·소모량은 포함하지 않음**):

한국어 기본 문구:

```text
세션「重构登录模块」 완료. 클릭하여 확인.     ← 완료
세션「重构登录模块」 중단됨. 클릭하여 확인.   ← 중단
세션「重构登录模块」 차단됨. 클릭하여 확인.   ← 차단
세션「重构登录模块」 한도 도달. 클릭하여 확인. ← 한도 도달
세션「重构登录模块」 오류. 클릭하여 확인.     ← 오류
```

> 세션에 제목이 없으면(`titleValue`가 비어 있음) 「세션 완료. 클릭하여 확인.」으로 폴백합니다. 소요 시간·소모량·캐시 적중률·속도는 커스텀 템플릿에서 `{duration}` `{usage}` `{cache}` `{tps}`를 삽입한 경우에만 표시됩니다.

커스텀 템플릿(설정 패널에서 편집하며, 이 예시는 모든 정보 슬롯 사용):

```text
{title} 干完了！用时 {duration}，消耗 {usage}，缓存命中 {cache}，速度 {tps}
```

렌더링 결과:

```text
重构登录模块 干完了！用时 3 分 25 秒，消耗 103,600 输入 / 35,600 输出，缓存命中 96.5%，速度 92 tok/s
```

5개 언어의 동일한 이벤트:

```text
会话「重构登录模块」已完成（用时 3 分 25 秒，消耗 1,240 输入 / 3,560 输出）。
會話「重構登入模組」已完成（用時 3 分 25 秒，消耗 1,240 輸入 / 3,560 輸出）。
Session "重构登录模块" completed (took 3m25s, used 1,240 in / 3,560 out).
セッション「重构登录模块」完了（所要 3 分 25 秒、消費 1,240 入力 / 3,560 出力）。
세션「重构登录模块」 완료（소요 3분 25초, 소모 1,240 입력 / 3,560 출력）。
```

### 알림 권한

| 권한 상태 | 동작 |
| --- | --- |
| `default`(미결정) | 완료 이벤트는 toast만 표시. 설정 패널 「알림 권한」 영역에 「권한 요청」 버튼 제공(**사용자 제스처 내에서 요청** —— Chromium은 제스처가 아닌 자동 요청을 무시하므로 플러그인은 자동 요청하지 않음) |
| `granted` | 「비포커스 시」「포커스 시」 각각에서 고른 채널로 시스템 알림 전송(독립 tag, 서로 덮어쓰지 않음. 「알림 안 함」이면 그 경로에서는 표시되지 않음) |
| `denied`(브라우저가 차단) | toast만. 설정 패널에 주소창 조작 안내 표시(권한 아이콘 → 사이트 설정 → 알림 → 허용) |
| `undefined`(비보안 컨텍스트 / 미지원) | toast만. 「페이지 내 알림만」으로 전환 권장 |

---

## 설정

대부분의 설정은 **DSH Web UI → 설정 → 플러그인 → 세션 완료 알림** 패널에서 완료됩니다(저장 후 「클릭하여 새로고침」을 누르면 적용). 「트리거 사유 화이트리스트」만 호스트 `cordis.patch.yml`의 `config`에서 설정합니다(하위 에이전트 건너뛰기는 패널의 체크박스로 제어).

### 설정 패널

패널은 공식 「설정 → 플러그인」 패널에 등록되며(`settings.plugin.item` keyed slot, key = `session-complete-notify`), 스타일은 네이티브 플러그인 카드를 값 단위로 재현합니다(12px 라운드 코너, 펼치기/접기, 회전 chevron, footer 상태 표시 + 폐기 ghost + 메인 컬러 저장 버튼):

| 영역 | 내용 |
| --- | --- |
| 프리셋 | 드롭다운으로 내장 또는 커스텀 프리셋 선택. 「추가」는 현재 설정을 커스텀 프리셋으로 별도 저장. 현재 프리셋은 「삭제」 가능 |
| 언어 | 5개 언어 중 단일 선택, 전환 시 패널 전체 즉시 재렌더링 |
| 비포커스 시 | 4택1: **알림 안 함**(해당 타이밍은 완전히 무음) / 이중 채널(시스템 알림 + 페이지 내 알림, 기본값) / 시스템 알림만 / 페이지 내 알림만 —— 창이 비포커스(탭 전환, 최소화, 다른 곳 클릭)일 때 적용 |
| 포커스 시 | 4택1: 위와 동일(기본값은 이중 채널) —— 창이 포커스일 때 적용. 두 경로는 서로 독립적이라 「비포커스 시 시스템 알림 + 포커스 시 알림 안 함」 같은 조합도 자유롭게 구성 가능 |
| 알림 미디어 | 큰 이미지의 두 가지 소스: **사유별 업로드**——템플릿에서 「＋ 정보 삽입 → 이미지」로 `{image}` 토큰을 삽입하고 로컬 이미지 선택(편집기에서 썸네일이 있는 칩으로 표시, **512px 너비·알림 표시 비율 16:9 중앙 크롭**으로 자동 압축, 사유별 저장). **전역 이미지/아이콘**——업로드 카드 2개를 한 줄에 나란히 배치(**아이콘이 먼저**. 빈 상태는 둥근 모서리 "+" 타일, 클릭하면 업로드. **이미지 512×288(16:9 중앙 크롭), 아이콘 128×128(1:1 정사각 중앙 크롭)**. 업로드 후 카드에 썸네일 표시, **클릭하면 전체 화면 미리보기(비율 유지·크롭 없는 원본 이미지)**, 오른쪽 위 ×로 삭제). 아이콘은 비우면 사이트 기본 아이콘, 또는 템플릿에 `{icon}` 토큰을 삽입해 **사유별 아이콘** 지정(전역보다 우선). 시스템 알림 채널에만 적용(페이지 내 토스트는 텍스트 카드). 「보내기」 테스트 버튼에도 동일 적용 |
| 제목 | 접이식 섹션(**기본 접힘**, 클릭하면 펼침): **전역 푸시 제목**(모든 이유 공통. Chip 편집기——「＋ 정보 삽입」으로 삽입한 정보는 **캡슐 태그**로 표시되고, 클릭으로 제거. **알림 전송 시 제목 안의 정보 토큰(소요 시간/소모/오류/캐시 히트/속도)이 실제 값으로 대체되어 코드가 노출되지 않음**. 비워 두면 각 사유별 기본 제목 사용——완료=작업 완료, 오류=작업 오류, 중단=작업 중단, 차단=작업 차단, 상한=출력 상한 도달, 질문=AI가 질문했습니다) ＋ **사유별 제목**(6개 사유 각각 입력. 각 행에 "+" 삽입 버튼——삽입 가능한 정보 토큰(「질문」포함, 이미지/아이콘 제외), 커서 위치에 삽입; **전역 제목보다 우선**. 비우면 전역 또는 언어 기본값 사용) |
| 콘텐츠 | 접이식 섹션(**기본 접힘**, 클릭하면 펼침). 펼치면 각 사유(완료, 오류, 중단, 차단, 출력 상한, 질문)마다 **한 줄 레이아웃**(사유 라벨 + Chip 편집기 + "+" 삽입 버튼——메뉴 펼침 중에는 "−"로 변경 + **종이비행기 보내기 버튼**. 버튼은 직사각형이며 세로 중앙 정렬): **템플릿이 비어 있으면(기본 프리셋) 편집기에 기본 문구 표시**. 텍스트 + 인라인 정보 캡슐, 커서 위치에 삽입; `{image}`/`{icon}` 칩은 **썸네일 클릭으로 큰 이미지 미리보기, × 클릭으로만 삭제**(실수 삭제 방지), 다른 칩은 클릭으로 제거; **편집 후 비우면 "비우면 기본 문구 사용" 플레이스홀더 표시(선택·삭제 불가)**. 질문 행의 기본 문구는 「AI가 질문합니다: {question}」이며, `{question}`은 보낼 때 AI의 실제 질문으로 대체됩니다(삽입 메뉴에도 「질문」 토큰이 있으며 다른 토큰과 동일한 조작입니다) |
| 하위 에이전트 세션 건너뛰기 | 체크박스(저장 시 설정 문서에 함께 기록) |
| 알림 권한 | 상태 실시간 표시: 승인됨(초록) / 미승인(「권한 요청」 버튼 포함) / 브라우저가 차단함(주소창 조작 안내 포함) / 환경 미지원 |
| 사유별 제목 커스터마이징 | 접는 영역(기본 접힘): 각 종료 사유마다 독립 제목 입력 상자. 비워 두면 = 전역 템플릿 또는 언어 기본 제목 사용 |
| 저장 | 호스트 설정 문서에 기록(`language` / `templates` / `titleTemplate` / `titleTemplates` / `pushModeBlur` / `pushModeFocus` / `skipSubagents`). 저장 후 「클릭하여 새로고침」 링크 표시 |
| 초기화 | 원클릭으로 기본값 복원(**언어는 현재 선택 유지**, 제목/템플릿/비포커스·포커스 채널은 기본값으로 복원) 후 즉시 저장 |

> [!NOTE]
> 「비포커스 시」「포커스 시」 채널의 장단점: `dual`(기본값)은 Windows 시스템 알림과 페이지 내 toast를 동시에 띄우며, toast는 시스템 알림이 플랫폼에서 무음 처리되는 경우(집중 지원, 알림 배너 끄기)를 대비한 보험 채널입니다. 두 경로는 서로 독립적이라 「비포커스 시에는 시스템 알림, 포커스 시에는 방해하지 않기」 같은 조합도 가능합니다. **기존 설정은 영향받지 않습니다**——설정 문서에 `pushModeBlur` / `pushModeFocus`가 없으면 두 경로 모두 구버전의 단일 `pushMode` 값을 그대로 사용합니다. 하지만 **QQ 브라우저 등 중국산 Chromium 셸 브라우저는 `Notification`을 「브라우저 내장의 페이지 내 푸시 팝업」으로 렌더링합니다**(페이지 상단/모서리 배너, Windows 알림 센터를 거치지 않음) —— 이 경우 `dual`은 페이지 내에 두 개의 알림이 생깁니다(브라우저 내장 팝업 + 플러그인 toast). 이런 브라우저에서는 「페이지 내 알림만」을 선택하세요(`Notification`을 호출하지 않으므로 브라우저 내장 팝업이 나타나지 않고, 페이지 내에는 플러그인 고유의 작은 toast만 표시됨). 「시스템 알림만」 모드는 QQ 브라우저에서 무효입니다(항상 페이지 내 팝업으로 렌더링됨). 설정 패널의 각 사유별 「전송」 테스트 버튼도 이 영향을 받습니다——테스트 알림은 「포커스 시」 경로를 우선하고, 그 경로가 「알림 안 함」이면 「비포커스 시」 경로로 바꾸며, 둘 다 꺼져 있으면 `dual`로 되돌아가므로 미리보기는 항상 피드백을 줍니다.

> [!NOTE]
> 시스템 알림(`Notification` API) 표시 여부는 **브라우저와 사이트 접근 방식**이 함께 결정합니다: Edge/Chrome은 "익숙하지 않은" 사이트에 대해 **알림을 자동 차단**합니다(주소창에 「알림 차단됨」 표시) —— 주소창 왼쪽 권한 아이콘 클릭 → 사이트 설정 → 알림 → 허용하면 복구됩니다. `http://IP` 같은 비보안 컨텍스트 접근 시 `Notification`이 아예 존재하지 않으므로 「페이지 내 알림만」으로 전환하세요. 설정 패널의 「알림 권한」 영역에서 현재 상태를 실시간 표시하고 해당 조작 안내를 제공합니다(원클릭 권한 요청 가능). Firefox는 창이 포커스된 상태에서 알림이 페이지 내 배너로 표시되고, 포커스를 잃어야 시스템 알림 센터로 들어갑니다.

> [!NOTE]
> 패널의 「하위 에이전트 세션 건너뛰기」는 설정 문서의 boolean 값으로 저장됩니다. 호스트 `cordis.patch.yml`의 `config.skipSubagents`는 시작 시 기본값이며, 둘 중 하나라도 참이면 건너뜁니다.

### 문구 템플릿과 플레이스홀더

각 종료 사유마다 독립적인 템플릿 입력 상자가 있으며, **라벨이 곧 스위치**입니다 —— 템플릿에 해당 정보 라벨을 삽입해야 그 데이터가 표시됩니다:

| 플레이스홀더 | 의미 | 예시 값 |
| --- | --- | --- |
| `{title}` | 세션 제목(알림 제목 템플릿에서도 사용 가능) | `重构登录模块` |
| `{duration}` | 이번 턴 소요 시간(`turn/start` 시작 → `turn/end` 종료) | `3 分 25 秒` / `3m25s` |
| `{usage}` | token 소모(입력 = 비캐시 + 캐시 읽기 + 캐시 쓰기) | `1,240 输入 / 3,560 输出` |
| `{error}` | 오류 정보(오류 없으면 `none` 표시. 한 줄로 정리, 80자 잘림) | `connection timeout` |
| `{cache}` | 캐시 적중률(공식 프로젝션 기준, 데이터 없으면 비어 있음) | `96.5%` |
| `{tps}` | 생성 속도(공식 프로젝션 기준, 데이터 없으면 비어 있음) | `92 tok/s` |
| `{image}` | 사용자 지정 알림 이미지 스위치: 「＋ 정보 삽입」에서 삽입하고 로컬 이미지 선택(512px로 자동 압축), 사유별로 독립. 본문 렌더링 시 제거되며 세션 로그에 기록되지 않음. 토큰을 삭제하면 해당 사유의 이미지 데이터도 함께 지워짐 | — |
| `{icon}` | 사용자 지정 알림 아이콘 스위치: 「＋ 정보 삽입」에서 삽입하고 로컬 이미지 선택(128×128 정사각형으로 자동 압축), 사유별로 독립. 본문 렌더링 시 제거되며 세션 로그에 기록되지 않음. 전역 「알림 아이콘」보다 우선. 토큰을 삭제하면 해당 사유의 아이콘 데이터도 함께 지워짐 | — |
| `{question}` | **질문 행 전용 플레이스홀더**: 보낼 때 AI의 실제 질문 텍스트로 대체됩니다. 「＋ 정보 삽입」메뉴와 연동(「질문」토큰을 직접 선택 가능, 직접 입력한 `{question}`도 캡슐로 인식). 질문 채널에서만 사용 가능하며, 다른 사유 행에 넣으면 빈 문자열로 대체됩니다(리터럴 누출 방지) | `보고서 생성을 계속할까요?` |
| `{label}` | 폐기됨 —— 렌더링 시 자동 제거, 기존 템플릿은 계속 호환(삽입 메뉴에서 해당 옵션 제거됨) | — |

템플릿을 비워 두면 내장 기본 문구를 사용합니다(「세션「{title}」〇〇. 클릭하여 확인.」 형식으로 통일, 소요 시간·소모량 미포함). 접히는 행의 `summary`는 본문과 동일한 소스입니다(렌더링 결과 120자로 잘림) —— 접힌 행만 보는 사용자도 실제 제목과 소요 시간, 소모량을 확인할 수 있습니다.

### 프리셋 시스템

- **내장 프리셋**: 「기본」 하나뿐이며 기준선 역할을 합니다.
- **커스텀 프리셋**: `localStorage`(key = `dsh-scn-custom-presets`)에 저장됩니다:
  - 「추가」로 이름을 지은 뒤 커스텀 프리셋으로 저장. 저장 후 「수정」으로 자동 동기화, 「삭제」로 제거 가능.
  - **자동 번호가 매겨진 이름 없는 프리셋**: 「기본 / 비어 있음」에서 바로 저장하면 `이름 없음`, `이름 없음 2`, `이름 없음 3`…이 자동 생성됩니다(번호는 현재 최댓값 + 1).
  - 폼에 「출처: xxx · 수정됨」 출처 표시가 나타납니다(프리셋에서 왔지만 내용이 변경된 경우).
- **저장 즉시 동기화**: 저장 시 폼의 출처가 커스텀 프리셋이면 해당 프리셋을 갱신하고, 그렇지 않으면 새로 만들거나 이름 없는 프리셋 번호를 계속 매깁니다.

### 호스트 설정 항목

```yaml
- insert:
    - id: dsh-session-notify
      name: '@telosmaylx/dsh-session-notify'
      config:
        reasons: [completed, aborted, blocked, error, max-tokens]
        skipSubagents: true
```

| 필드 | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `reasons` | `string[]` | `[completed, aborted, blocked, error, max-tokens]` | 알림을 트리거하는 `turn/end` 사유 화이트리스트 |
| `skipSubagents` | `boolean` | `true` | 하위 에이전트 세션 건너뛰기(`origin=subagent` 또는 `delegationDepth>0`) |

---

## 동작 원리

플러그인은 **호스트 플레인**(Node)과 **클라이언트 플레인**(브라우저)으로 나뉘며, 사이는 세션 로그(JSONL)와 공식 세션 프로젝션으로 이어집니다:

```text
┌─────────────────── 宿主平面（lib/index.js，Node）──────────────────┐
│                                                                     │
│  session/event 火线                                                 │
│   ├─ turn/start        → tracker 起表（key: sessionId:turn）        │
│   ├─ assistant/message → 累加该轮 token 用量                        │
│   ├─ tool/call         → ask_user_question？写提问投影（标题+正文） │
│   └─ turn/end          → reason.kind ∈ reasons ？                   │
│                            ├─ 子代理会话？跳过                       │
│                            ├─ 读官方投影：cache / tps / title        │
│                            ├─ 按语言+模板构建通知（summary ≤120 字） │
│                            └─ queueMicrotask 追加系统消息            │
│                                 （避开 append 重入窗口）             │
│                                                                     │
│  settings.register   → 官方「设置 → 插件」命名空间（失败退避重试）   │
│  sessionProjections  → 注册投影单元（key=session-complete-notify）  │
│                        + 提问投影（key=session-complete-notify-     │
│                          question，等待回答期间持续推送）            │
└──────────────────────────────┬──────────────────────────────────────┘
                               │ user/message (source: plugin, form: notice)
                               ▼  JSONL 持久化 + 投影推送
┌─────────────────── 客户端平面（lib/client.js，浏览器）──────────────┐
│                                                                     │
│  会话列表订阅：running true → false 边沿 → pushCompletion            │
│   ├─ 取正文：投影 → 事件窗口 notice → 降级（轮询 ≤6s）               │
│   ├─ Web Notification（独立 tag，点击聚焦）                          │
│   └─ 页内 toast（永远展示，≤3 条，10s 自动消失）                     │
│  提问投影轮询（key=session-complete-notify-question）：              │
│   有值 → 立即弹提醒（标题+正文），无值清空                            │
│                                                                     │
│  slots.inject('settings.plugin.item') → 设置卡片（预设/语言/模板）   │
└─────────────────────────────────────────────────────────────────────┘
```

### 핵심 설계 결정

- **재생 없음**: 실시간 이벤트만 처리하며, resume, replay는 과거 알림을 다시 보내지 않습니다.
- **자기 순환 없음**: 플러그인은 `user/message`를 추가하고 자체적으로 `turn/*`만 리슨하므로 이벤트 유형이 겹치지 않습니다.
- **외부 import 제로**: 플러그인은 저장소 디렉터리에서 realpath로 로드되며, `@deepseek-ai/*`는 bare로 해석할 수 없습니다 —— 호스트 플레인은 `createRequire`로 profile 공유 의존성 허브(`.dsh/profiles/node_modules`)를 고정해 `schemastery`(설정 schema)와 `zod`(프로젝션 schema)를 가져옵니다. UserMessage는 `dsh-llm` 계약에 따라 수동으로 구성됩니다(`id = crypto.randomUUID()`, deep-freeze는 `session.append`의 adopt 스냅샷 단계에서 완료).
- **append 재진입 회피**: `session/event` 관찰자 콜백은 `turn/end`의 그 append 게시 경계 안에서 실행됩니다(dsh-session은 dispatch 전에 `entry.appending`을 설정하고 `finally`에서 리셋). 동기 append는 거부됩니다 —— 따라서 `queueMicrotask`로 지연합니다(마이크로태스크는 이번 동기 스택이 `finally` 리셋을 포함해 끝난 뒤에 실행됨).
- **effect 규율**: 설정 등록의 백오프 재시도 타이머를 `ctx.effect()`에 감싸 `clearTimeout` disposer를 반환합니다 —— 플러그인이 재시도 창 안에서 언로드되거나 핫 리로드되면 타이머가 fiber와 함께 제거되어 해제된 ctx에 대해 등록을 트리거하지 않습니다(아주 오래된 환경에 `ctx.effect` API가 없으면 일반 타이머 + ctx 제거 시 폴백 캐치로 대체).
- **HMR 안전**: `core.js` import에 `?v=1` 캐시 버스팅을 붙입니다(HMR 리로드는 URL 키 기준). 설정 등록 시 핫 리로드 경합(duplicate)을 만나면 자동 백오프 재시도합니다(최대 8회, 간격 `400ms × attempts`).
- **프로젝션 등록 이중 트랙**: 우선 `ctx.root.get('sessionProjections')`(호스트 루트에 가장 가까운 것)를 사용하고, 얻지 못하면 주입 인스턴스로 폴백합니다. 주입 인스턴스에만 등록하면 클라이언트가 프로젝션 유닛을 읽지 못할 수 있어 알림 본문이 폴백 경로를 타게 됩니다 —— best-effort이며 세션 내 시스템 메시지에는 영향을 주지 않습니다.

---

## 프로젝트 구조

```text
dsh-session-notify/
├── lib/
│   ├── index.js      # 宿主平面（Node）：session/event 订阅 → 系统消息落盘；
│   │                 #   settings 命名空间注册（schemastery schema，退避重试）；
│   │                 #   sessionProjections 投影单元（后台会话推送正文）
│   ├── core.js       # 纯逻辑层（零依赖，可独立测试）：轮次计时与用量聚合、
│   │                 #   5 语言文案表、时长/用量/缓存/速度格式化、
│   │                 #   模板渲染（{title}{duration}{usage}{error}{cache}{tps}）、
│   │                 #   提问正文构建（buildQuestionBody，{question} + 媒体剥除）
│   └── client.js     # 浏览器平面：完成推送（系统通知 + toast）、
│                     #   设置卡片（Chip 模板编辑器 + 预设系统 + 实时预览）
├── scripts/
│   ├── build.sh                # 零构建：仅 node --check 语法校验
│   ├── verify-notice.mjs       # 校验会话日志落盘证据（zstd 多帧逐帧解压）
│   ├── probe-client.mjs        # 探针：客户端装配
│   ├── probe-client-e2e.mjs    # 探针：客户端端到端
│   ├── probe-card-render.mjs   # 探针：设置卡片渲染
│   ├── probe-settings-card.mjs # 探针：设置面板卡片
│   ├── probe-settings-check.mjs# 探针：设置面板检查
│   └── probe-diag-settings.mjs # 探针：settings 诊断
├── cordis.patch.yml  # dsh.bundle manifest —— dsh plugin add 自动挂载的凭证
├── package.json      # dsh.bundle（patch）+ dsh.client（web 注入）双 manifest；
│                     #   exports: "." / "./client" / "./core"
├── LICENSE           # MIT
└── README.md         # 本文档
```

---

## 개발 및 디버깅

문법 검증(제로 빌드, `prepublishOnly`와 동일한 검사):

```bash
npm run build
```

배포(배포 전에 `prepublishOnly` 문법 검증 자동 실행):

```bash
npm publish --registry=https://registry.npmjs.org --access public
```

오프라인 검증: 세션 로그에서 모든 plugin-source 이벤트와 `turn/end` 꼬리 시퀀스를 추출합니다(경로를 넘기지 않으면 `~/.dsh/sessions`에서 가장 최근 세션 자동 선택):

```bash
node scripts/verify-notice.mjs <session.jsonl.zstd>
```

### 디버깅 진입점

| 진입점 | 내용 |
| --- | --- |
| `~/.dsh/session-complete-notify.log` | 호스트 진단 로그: 설정 등록, 재시도 및 실패, 프로젝션 등록, 추가 실패 스택 |
| 브라우저 console `[dsh-session-notify-client]` | 클라이언트 로그: 권한 상태, 알림 표시, 설정 저장 |
| `window.__dsch_notify_debug.readNotice(id)` | 지정 세션의 최신 알림 본문 수동 읽기 |
| `window.__dsch_notify_debug.snapshotDebug(id)` | 세션 꼬리 노드 유형 + notice 개수 + 최근 본문(앞 200자) |

---

## 자주 묻는 질문

<details>
<summary><b>npm install 후에 왜 자동으로 마운트되지 않나요?</b></summary>

이는 DSH의 공식 설계입니다: `npm install`은 패키지를 의존성 트리에 넣을 뿐 플러그인을 등록하지 않습니다. 자동 마운트의 유일한 경로는 `dsh plugin add` —— 패키지 내 `dsh.bundle` manifest(이 플러그인은 0.1.3부터 선언)를 읽고 `cordis.patch.yml`을 자동 적용합니다. [설치](#설치)를 참고하세요.

</details>

<details>
<summary><b>AI가 질문해도 팝업 알림이 뜨나요?</b></summary>

뜹니다. AI가 `ask_user_question`을 호출해 답변을 기다리면, 호스트가 즉시 「질문 제목 + 본문」을 전용 프로젝션(key = `session-complete-notify-question`)에 기록하고, 클라이언트가 폴링으로 값을 얻으면 즉시 알림을 띄웁니다——다른 페이지를 보고 있어도 놓치지 않습니다. 질문 문구는 완료 알림과 똑같이 완전히 커스터마이징할 수 있습니다. 설정 패널의 「제목 / 콘텐츠」 접이식 섹션에 각각 「질문」 행이 있으며, 본문은 `{question}` 플레이스홀더(AI의 실제 질문 주입)와 `{image}` / `{icon}` 미디어 스위치를 지원합니다. 답변 후(`tool/result`)에는 알림이 무효화되어 남지 않습니다.

</details>

<details>
<summary><b>왜 「중단」(interrupted)은 알림을 보내지 않나요?</b></summary>

`interrupted`는 크래시 복구 후 영속화 백엔드가 보완한 고아 턴 종료 표시이며, 사용자 관점의 「완료」에는 포함되지 않습니다(그렇지 않으면 세션 복구 시 오보로 화면이 넘칩니다). 꼭 필요하면 호스트 설정의 `reasons`에 추가할 수 있습니다.

</details>

<details>
<summary><b>백그라운드 세션(창을 열지 않은)도 알림이 오나요?</b></summary>

네. 클라이언트는 세션 목록 스냅샷에서 모든 세션의 `running` 엣지를 관찰합니다. 본문은 우선 호스트 프로젝션을 가져옵니다 —— 호스트가 모든 세션(백그라운드 포함)에 대해 프로젝션 유닛을 유지하므로 알림 본문이 세션 간에 일관됩니다. 프로젝션을 사용할 수 없으면 이벤트 윈도우 또는 작업 영역 정보로 폴백합니다.

</details>

<details>
<summary><b>설정 저장 후 왜 페이지 새로고침을 안내하나요?</b></summary>

호스트는 네임스페이스 등록 시 설정을 한 번 읽고, 클라이언트 bundle은 페이지 로드 시 어셈블됩니다. 저장 후 「클릭하여 새로고침」을 누르면 양쪽이 다시 읽어 새 언어와 템플릿이 적용됩니다.

</details>

<details>
<summary><b>캐시 적중률, 속도 데이터는 어디서 오나요? 왜 때로는 비어 있나요?</b></summary>

공식 `sessionProjections`(`tokenUsage`, `sessionStats`)에서 가져오며 dsh-web-ui 상태 표시줄과 동일한 기준입니다. 호스트가 프로젝션 스냅샷을 읽지 못하거나 데이터가 아직 준비되지 않으면 로컬 사용량 집계 추정으로 폴백하고, 그래도 데이터가 없으면 해당 항목은 비어 있습니다(라벨을 삽입해도 표시되지 않음). 또한 이 두 항목은 커스텀 템플릿에서 `{cache}`, `{tps}`로 삽입할 때만 나타나며 기본 문구에는 포함되지 않습니다.

</details>

<details>
<summary><b>알림 본문의 오류 정보가 너무 길고 줄바꿈이 있으면 어떻게 하나요?</b></summary>

요약 행(접히는 행)과 오류 상세 모두 한 줄로 정리되어 잘립니다: 요약 120자, 템플릿 `{error}` 80자, 기본 문구의 오류 상세 40자, 초과 시 말줄임표로 끝납니다.

</details>

<details>
<summary><b>시스템 알림의 아이콘이나 소리를 커스터마이징할 수 있나요?</b></summary>

아이콘은 **커스터마이징 가능**합니다: 설정 패널의 「알림 이미지」 영역에서 **알림 히어로 이미지**와 **아이콘**을 업로드할 수 있습니다(전역). 각 사유 템플릿에 `{icon}` 태그를 삽입하면 해당 사유 전용 아이콘도 지정할 수 있습니다(전역보다 우선). **소리**는 커스터마이징할 수 없습니다(시스템/브라우저 기본값 사용). toast는 고정된 다크 카드입니다. 기타 필요 사항이 있으면 Issue 또는 PR을 환영합니다.

</details>

<details>
<summary><b>왜 Edge는 시스템 알림을 보내지 못하나요? QQ 브라우저는 왜 페이지 내 배너(내장 푸시 팝업)만 있나요?</b></summary>

둘 다 브라우저 동작이며 플러그인이 강제할 수 없습니다:

- **Edge / Chrome**: "익숙하지 않은" 사이트에 대해 **알림을 자동 차단**합니다(주소창에 「알림 차단됨」 표시). 주소창 왼쪽 권한 아이콘 클릭 → 사이트 설정 → 알림 → 허용하면 복구되며, 이후 Windows 알림 센터가 정상적으로 표시됩니다. 브라우저 알림 설정에서 「자동 차단」을 끌 수도 있습니다.
- **QQ 브라우저 등 중국산 Chromium 셸**: `Notification`을 고정적으로 **브라우저 내장의 페이지 내 푸시 팝업**(페이지 상단/모서리 배너, Windows 알림 센터 미경유)으로 렌더링하며 시스템 알림 옵션이 없습니다. 「비포커스 시」「포커스 시」 두 드롭다운은 이런 브라우저에서 동일하게 동작합니다:
  - `시스템 + 페이지 내` → 브라우저 내장 팝업 + 플러그인 toast, 페이지 내 알림 2개;
  - `시스템만` → 무효(QQ 브라우저는 항상 페이지 내 팝업으로 렌더링);
  - `페이지 내만` → 브라우저 내장 팝업이 나타나지 않고 페이지 내에는 플러그인 고유의 작은 toast만 표시(권장);
  - `알림 안 함` → 해당 타이밍은 완전히 무음.
  설정 패널의 각 사유별 「전송」 테스트 버튼도 이 규칙에 따라 렌더링됩니다.
- **Firefox**: 창 포커스 시 알림이 페이지 내 배너로 표시되고, 포커스 해제/최소화 시에만 시스템 알림 센터로 들어갑니다. 권한은 주소창에서 수동으로 허용해야 합니다.
- 참고: `http://IP` 접근(비보안 컨텍스트) 시 `Notification`이 존재하지 않아 어떤 브라우저에서도 시스템 알림을 띄울 수 없습니다.

설정 패널 「알림 권한」 영역에서 현재 상태와 해당 조작 안내를 실시간으로 표시합니다.

</details>

---

## 변경 로그

| 버전 | 날짜 | 변경 내용 |
| --- | --- | --- |
| **0.1.21** | 2026-09-14 | **푸시 채널을 비포커스 / 포커스로 분리**([PR #3](https://github.com/TelosmaYLX/dsh-session-notify/pull/3)을 [@YiHui-Liu](https://github.com/YiHui-Liu) 님이 기여): 독립적인 「비포커스 시」「포커스 시」 드롭다운 2개를 추가해 각각 `알림 안 함` / `시스템 + 페이지 내` / `시스템만` / `페이지 내만` 선택 가능; 구버전의 단일 「알림 방식」 설정을 제거하고, 새 항목이 설정되지 않으면 두 경로 모두 구버전 `pushMode` 값을 그대로 사용(기존 설정 동작 불변); 특정 타이밍을 「알림 안 함」으로 두면 그 타이밍은 완전히 무음(질문과 승인은 중복 제거를 하지 않으므로 페이지가 다른 타이밍으로 넘어가면 같은 이벤트가 다시 알림. 완료는 엣지 이벤트라 무음이면 재발송하지 않음); 「전송」 테스트 알림은 「포커스 시」 경로를 우선하고, 그것이 「알림 안 함」이면 「비포커스 시」 경로로 바꾸며, 둘 다 꺼져 있으면 `dual`로 폴백 |
| **0.1.20** | 2026-09-09 | **승인 즉시 알림 + 히스토리 세션 로드 수정**：권한 승인 알림 추가([PR #2](https://github.com/TelosmaYLX/dsh-session-notify/pull/2)를 [@YiHui-Liu](https://github.com/YiHui-Liu) 님이 기여 — `approval/asked` 프로젝션 + 클라이언트 3중 신호 폴백). 0.1.19 회귀 수정: 프로젝션 등록을 두 세대 계약 병기(`schema`/`view` + `stateSchema`/`wire`)로 바꿔 구형 호스트에서 히스토리 세션을 열 때 `undefined.parse`로 실패하지 않음. 클라이언트 `uiSession`을 `inject`에서 제거하고 `ctx.get` 선택 조회로 전환해 서비스 부재 시 알림·설정 패널 전체가 멈추는 문제를 방지 |
| **0.1.19** | 2026-09-07 | **질문 팝업 수정(호스트 프로젝션 연결 끊김)**：프로젝션 단위 등록을 `stateSchema` + `wire: { viewSchema, view }` 계약으로 이전 — 구 형태(최상위 `schema`/`view`)는 신형 호스트(dsh-session-projection)에서 host-only 단위가 되어 값이 클라이언트에 전달되지 않으므로 완료·질문 프로젝션 모두 무효화. `tool/call`의 `callId`가 빈 문자열이면(일부 OpenAI 호환 프록시 라우팅) 질문 id를 `turn:step`으로 폴백하고 `tool/result`도 turn/step 대조로 해제. 아울러 `stateSchema` 누락 시 프로젝션 checkpoint 복원 경로의 잠재 크래시도 수정 |
| **0.1.18** | 2026-09-01 | **질문 팝업 미발동 수정**：일부 dsh 버전(0.1.2)에서 호스트 프로젝션이 클라이언트에 전달되지 않아 질문해도 팝업이 뜨지 않던 문제를 수정. 클라이언트 질문 푸시에 harness 네이티브 '질문 대기' 마크 폴백을 추가하여 프로젝션 누락 시에도 알림. 완료 푸시·설정 패널 동작은 변경 없음 |
| **0.1.17** | 2026-08-30 | **질문 즉시 알림(커스터마이징)**：AI 질문 즉시 팝업. 질문 문구는 `{question}` 플레이스홀더와 미디어 스위치 지원. 프리셋 4종에 5개 언어 질문 문구 추가. 구버전 호스트 폴백 |
| **0.1.16** | 2026-08-30 | **조작 수정**：Backspace 연타 시 태그 오삭제 방지(커서와 태그 사이에 텍스트가 없을 때만 태그 삭제) |
| **0.1.15** | 2026-08-30 | **조작 개선**：「콘텐츠」 접이식 영역 기본 펼침. 태그 삭제 후 커서가 실제 내용으로 이동해 연속 삭제 가능 |
| **0.1.14** | 2026-08-30 | **코드 리뷰 수정**：프리셋 삭제 확인, 저장된 구성으로 복원, 미디어 「×」로 미리보기 제거, 프리셋 매칭에 이미지 포함, 동명 경고, 수정 시에만 초기화 가능, 디버그 로그 자동 절단 |
| **0.1.13** | 2026-08-29 | 원클릭 스타일 프리셋 4종 추가(카오모지/아이루/네코무스메/DeepSeek짱), 5개 언어 지원 |
| **0.1.12** | 2026-08-29 | 릴리스 패키지 정리 |
| **0.1.11** | 2026-08-29 | **사용자 지정 알림 미디어**：`{image}`/`{icon}` 토큰 삽입 및 이미지/아이콘 업로드(자동 크롭). 제목에 정보 플레이스홀더. 「본문 템플릿×5」를 접이식으로. 레이아웃·조작 전면 개선 |
| **0.1.10** | 2026-08-29 | 푸시 제목을 네이티브 입력으로. 다국어 README 추가(English/繁體/日本語/한국어) |
| **0.1.9** | 2026-08-29 | 사유별 푸시 제목. 프로젝션을 객체로 업그레이드. 초기화 시 언어 유지. 사유별 「전송」 테스트 버튼 |
| **0.1.8** | 2026-08-29 | 기본 제목 「작업 완료」. 사유별 기본 문구. 초기화 버튼 추가 |
| **0.1.7** | 2026-08-29 | 설정 카드 크래시 수정(알림 권한 행 스코프 문제) |
| **0.1.6** | 2026-08-29 | 알림 권한 상태 영역 추가. 사용자 제스처 내 권한 요청 |
| **0.1.5** | 2026-08-29 | 알림 채널 설정(이중/시스템만/페이지 내만)——QQ 브라우저 이중 알림 해결 |
| **0.1.4** | 2026-08-28 | 완전한 제거 지원(dispose 생명주기 정리) |
| **0.1.3** | 2026-08-28 | dsh.bundle manifest 선언. settings 재시도 타이머를 ctx.effect()로 |
| 0.1.2 | 2026-08-27 | `@telosmaylx` 스코프로 이름 변경 |
| 0.1.1 | 2026-08-27 | GitHub / npm 설치 방법 문서화 |
| 0.1.0 | 2026-08-26 | 초기 버전: 세션 내 시스템 메시지 + 브라우저 알림 + 공식 설정 패널 |

---

## 감사

[@YiHui-Liu](https://github.com/YiHui-Liu) 님의 두 가지 기여에 감사드립니다: [PR #2](https://github.com/TelosmaYLX/dsh-session-notify/pull/2) — 권한 승인 즉시 알림(`approval/asked` 프로젝션 + 클라이언트 3중 신호 폴백); [PR #3](https://github.com/TelosmaYLX/dsh-session-notify/pull/3) — 푸시 채널을 비포커스 / 포커스로 분리하고 두 경로 모두에 「알림 안 함」 제공.

---

## 기여

Issue와 PR을 환영합니다:

1. 저장소를 Fork하고 새 브랜치를 만듭니다(`feat/xxx`)
2. 변경 후 `npm run build`를 실행해 문법을 검증합니다
3. PR을 제출하고 동기와 검증 방법을 설명합니다

제출 전에 [Cordis 개발 튜토리얼](https://deepseek-harness.github.io/deepseek-harness/develop/cordis-tutorial) 규율을 준수하세요:

- Cordis 외부 리소스(타이머, 구독, watcher)는 반드시 `ctx.effect()`에 감싸 disposer를 반환해야 합니다.
- 설정 항목에 명시적 `id`를 사용해 편집 드리프트를 방지합니다.
- 플러그인은 `dsh.bundle` manifest를 선언해야 `dsh plugin add`로 인식되어 설치됩니다.

---

## 관련 링크

- [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) —— DSH 플러그인 선별 목록(제출 규칙: `dsh.bundle`이 유일한 설치 증명)
- [Cordis 개발 튜토리얼](https://deepseek-harness.github.io/deepseek-harness/develop/cordis-tutorial) —— 플러그인 개발 전체 프로세스(01-07장)
- [npm 패키지 홈페이지](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
- [GitHub 저장소](https://github.com/TelosmaYLX/dsh-session-notify)

---

## 라이선스

[MIT](./LICENSE) © dsh-session-notify contributors
