# dsh-focus-notify

[![CI](https://github.com/sqzw-x/dsh-focus-notify/actions/workflows/ci.yml/badge.svg)](https://github.com/sqzw-x/dsh-focus-notify/actions/workflows/ci.yml)

English | [中文](README.md)

Focus-aware notifications for DeepSeek Harness: **approvals, questions, plan reviews and finished replies all arrive as one kind of alert**. Three rules, no more.

## Rules

1. **By conversation**: the conversation on screen only alerts once its window loses focus; every other conversation alerts always, even with DSH in the foreground.
2. **By event**: a finished turn and a request waiting on you (approval / plan review / question) both alert.
3. **Coming back clears it**: focusing a conversation makes its notification go away — clicking the notification, switching back to the window, or picking the conversation by hand.

Subagent sessions are the one exception: they are never "the conversation on screen" and never alert (`ignoreSubagent`, can be turned off).

### Why not "is the page visible"

On macOS, `document.hidden` stays `false` while the window is merely covered by another app — only minimizing (or ⌘H) flips it. A plugin that treats "visible" as "the user is reading" therefore drops approval and question alerts exactly when you are working somewhere else. This plugin uses `document.hasFocus()`: **no keyboard focus means you are away**. The on-screen conversation therefore stays quiet only while you are actually looking at it — and symmetrically, clearing an alert also requires keyboard focus: switching conversations while the window is blurred leaves notifications alone, since you may still be in another app.

## Testing status

**Only DSH Desktop has been exercised in practice. Whether the Web build works is unknown** — the code has browser-semantics branches (`window.focus()` and friends), but nobody has verified them.

## Install

```sh
dsh plugin --profile desktop add github:sqzw-x/dsh-focus-notify
```

From a local directory (development):

```sh
dsh plugin --profile desktop add file:/absolute/path/to/dsh-focus-notify
```

Once the npm package is published, `dsh plugin --profile desktop add dsh-focus-notify`
is the shorter path.

Restart DSH Desktop afterwards; the client bundle is read when the page loads. Disable without uninstalling:

```yaml
- id: dsh-focus-notify
  disabled: true
```

## Configure

```js
__dshFocusNotify.configure({ sound: true })
__dshFocusNotify.debug()
__dshFocusNotify.test()
__dshFocusNotify.open('session-id')   // switch conversations by id (defaults to the current one)
```

Two `debug()` fields exist for "I clicked it and nothing happened":

- `navigation` — which service a click will switch conversations through: `uiWorkspace.openSession` / `sessions.open` / `none`.
- `counters.failed` — how many navigation calls threw; each one also logs a `[dsh-focus-notify] …` warning.

Every posted notification also dispatches a `dsh-focus-notify:alert` event on `window` (`event.detail` is the alert: `kind` / `sessionId` / `title` / `body` / `tag`), which is where to hang your own reminder — a flashing taskbar, a different sound, a log line.

| Field | Default | Meaning |
| --- | --- | --- |
| `enabled` | `true` | master switch |
| `ignoreSubagent` | `true` | subagent sessions stay silent |
| `language` | `'auto'` | copy language: follows the DSH locale, or pin `'zh'` / `'en'` |
| `sound` | `false` | optional WebAudio chime on top of the notification |

## How it works

Two read-only stores: `uiSession.sessionStatus` (`running`, `pendingInteraction`) and `sessions.list` (title, `origin: 'subagent'`, `retainedBy.mainView`). They produce the two event kinds behind rule 2: a **wait** is a pending interaction appearing (deduped by request key, so one request alerts once), and a **completion** is that session's `running` bit falling with no wait in its place — stopping to ask you something is a wait, not a completion. Every decision lives in the DOM-free `src/engine.ts`, which is unit-tested directly.

Clicking a notification raises the window (the `dsh://open` deep link on the desktop, `window.focus()` in a browser) and switches to that conversation through the host's `uiWorkspace.openSession` (`sessions.open` on older hosts), bound to its instance before the call; a failure is counted in `counters.failed` and warned about instead of being swallowed the way 0.2.0 swallowed it; the manual ways back (the window regaining focus, switching conversations by hand) end in the same place — the conversation's notification is closed. Notifications you never return to are not auto-closed; the platform style decides how long they stay.

## Known limitations

- **The Web build is unverified**: only DSH Desktop has been tested; browser behaviour (raising the window, the permission flow) has not been checked by anyone.
- Switching conversations needs a host navigation service: `debug().navigation` reading `none` means the host publishes neither `uiWorkspace.openSession` nor `sessions.open`, and a click can only raise the window.
- Works inside DSH Web / DSH Desktop pages only; `dsh-app://` pages cannot register a Service Worker, so notifications carry no buttons (this plugin needs none).
- "The conversation on screen" is derived from `retainedBy.mainView`, which is a **reference count the main view holds** (upstream's own comment: "Local ownership counts, independent of catalog membership and never persisted"), read with the same predicate upstream `isMain()` uses. Where the host does not publish it, every conversation counts as "not current" (and therefore alerts).
- Notification presentation is up to the platform (macOS: System Settings → Notifications → DeepSeek Harness).
- A denied permission means no notifications; `__dshFocusNotify.debug().permission` reports `denied`.
- **Completions are detected from the `running` edge, not from the SDK's `completionUnread`.** The latter is the sidebar's unread dot: upstream clears it while the main view holds the session and as soon as `running` turns true, so using it as the "reply finished" signal would silently stop background-completion alerts. The trade-off is that a reply finishing before the client ever observes `running === true` (say, across a page reload) does not alert.
- `dsh.compatibility.dshReleases` in `package.json` is **informational only**: nothing in DSH 0.2.0-rc.2 reads it. The real gate is `peerDependencies` on `@deepseek-ai/dsh*` — this package declares `@deepseek-ai/cordis`, whose prefix is not checked, so that peer is inert, exactly as in upstream packages like `dsh-client-ui-session`.

## Development

Node 22.18+ (toolchain only: tsdown requires `^22.18.0 || ^24.11.0 || >=26.0.0` and CI runs 22/24 — the shipped bundle runs in the browser and does not care about the Node version).

```sh
npm install && npm test
```

Four test layers: `test/engine.test.mjs` exercises the rule table, `test/client.test.mjs` loads the built bundle in a vm the way the host does (against a host double shaped like the real one), `test/host.test.mjs` mounts the same bundle as a real cordis fiber on a real `Context`, and `test/manifest.test.mjs` guards the manifest and artifact contract. The rule table is what the tests run against; **the Desktop-only status above is about real-world use, not about what is covered here**.

## Publishing

Releases use npm **Trusted Publishing** (OIDC); the workflow needs no long-lived `NPM_TOKEN`.

1. **First release**: `dsh-focus-notify` does not exist on npm yet. Publish once locally (`npm login` then `npm publish --access public`) or with a one-off token in CI; this creates the package and its ownership.
2. On npmjs.com open the package → **Settings → Trusted publishing** and add GitHub Actions:
   - Organization or user: `sqzw-x`
   - Repository: `dsh-focus-notify`
   - Workflow filename: `publish.yml`
   - Allowed actions: enable **npm publish** (`npm stage publish` is always allowed)
3. After that, push a `v*` tag matching the `package.json` version; `.github/workflows/publish.yml` publishes over OIDC. A new configuration must complete one successful publish within **2 days** or it expires; if 0.3.0 was published manually, `npm version patch` to 0.3.1 and tag that (`npm version` also syncs `src/version.ts`).
4. Once verified, delete any `NPM_TOKEN` and set the package's Publishing access to “Require two-factor authentication and disallow tokens”.

```sh
# If the first version was already published manually: bump and verify via CI
npm version patch && git push --follow-tags

# Later releases
git tag "v$(node -pe 'require("./package.json").version')" && git push origin --tags
```

## License

MIT
