# dsh-windows-notification — build contract (shared by all agents) Status: v1 implementation. Read this whole file before writing code. It is the single source of truth for file ownership, runtime contracts and verification. If reality contradicts this document, report the contradiction to the Lead instead of silently diverging. ## Product A DeepSeek Harness plugin that posts **native Windows toast notifications** when the harness wants the user's attention: - a turn finishes (`turn/end` with reason `completed`), - a turn fails (`turn/end` with reason `error`), - an approval is requested (`approval/request`). Requirement from the user: the notification must **appear to come from the DeepSeek Harness desktop app itself** (not from Windows PowerShell), and it must be **actionable like Codex's notifications** (buttons that do something). Facts verified on this machine: - DSH's registered AppUserModelID is `com.deepseek.dsh` (Start Menu shortcut `%APPDATA%\Microsoft\Windows\Start Menu\Programs\DeepSeek Harness.lnk`, `Get-StartApps` shows it). Toasts created with `CreateToastNotifier('com.deepseek.dsh')` are accepted by the notification platform (they land in notification history under `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\com.deepseek.dsh`) and are attributed to DeepSeek Harness with its icon. - The host process is plain Node (`ELECTRON_RUN_AS_NODE`), so Electron's Notification API is NOT reachable. Windows PowerShell 5.1 (`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`) + WinRT `Windows.UI.Notifications` is the transport. - The machine currently suppresses banner display (DND/focus). Do not treat "no banner on screen" as failure by itself: verify via WinRT toast history, the registry stamp, and (when possible) the notification center. ## Directory and file ownership (do not write outside your scope) Plugin root: `\` | Owner | Files | |---|---| | Lead | `package.json`, `cordis.patch.yml`, `locale/en.json`, `locale/zh.json`, `icon.svg`, `README.md`, install/verification scripts, profile wiring | | toast-engineer | `toast.ps1`, `notify-handler.ps1`, `setup-protocol.ps1`, `.verify/toast/**` | | host-engineer | `index.js`, `.verify/host/**` | | client-engineer | `client.js`, `.verify/client/**` | Scratch/verification artifacts go under `/verify/notification/` (subdirectories per owner). Never edit another owner's files. Never touch `~/.dsh/profiles/**` — the Lead owns installation. ## Runtime architecture ``` DSH host process (plain Node, index.js) ├─ ctx.on('session/event') turn/end → toast "finished" / "error" ├─ ctx.on('approval/request', …, { prepend: true }) │ when the DSH window is NOT foreground and approvalActions is on: │ show approval toast with 允许一次 / 拒绝 / 打开 DSH buttons, │ await the user's click or timeout, then return the ApprovalOutcome │ (fall back to next() so the in-app UI asks as usual) ├─ local HTTP bridge 127.0.0.1: + token │ POST /action {token, action:'open'|'approve'|'deny', sessionId?, approvalId?} │ → focuses DSH / resolves a pending approval ├─ child_process.spawn(powershell.exe, …) → toast.ps1 (show, check foreground) └─ writes %LOCALAPPDATA%\dsh-windows-notification\bridge.json for the protocol handler Windows notification click → protocol dsh-notify://… (registered once under HKCU\Software\Classes\dsh-notify) → notify-handler.ps1 %1 → POST to the bridge (approve / deny / open) → focus the DeepSeek Harness window (for open; restore if minimized) ``` ### Toast identity Every toast uses AUMID `com.deepseek.dsh`. Because that AUMID belongs to the DSH Start Menu entry, Windows shows the notification as "DeepSeek Harness" with its icon. Toast XML skeleton built by `index.js` (toast.ps1 only displays it): ```xml DeepSeek Harness …title / session title… …body / preview… ``` Approval toast actions: ```xml ``` (URI query values must be URI-encoded; approve/deny must carry the one-time `approvalId` generated by the host. `toast.ps1` must not rewrite the XML.) ## Settings namespace Namespace: `windows-notification` (lowercase hyphenated, required by the settings service). Resolved value (all fields required in the schema): | field | type | default | meaning | |---|---|---|---| | `enabled` | boolean | `true` | master switch | | `onTurnComplete` | boolean | `true` | notify when a turn completes | | `onApproval` | boolean | `true` | notify/handle approvals | | `onError` | boolean | `true` | notify when a turn fails | | `skipWhenForeground` | boolean | `true` | do not post automatic toasts while the DSH window is foreground | | `includePreview` | boolean | `true` | include the reply text preview in the body | | `sound` | `'default' \| 'silent'` | `'default'` | toast audio | | `approvalActions` | boolean | `true` | claim approvals while DSH is not foreground and answer from the toast | | `approvalTimeoutSec` | integer 5–300 | `30` | fall back to the in-app prompt after this many seconds | | `maxPreviewChars` | integer 40–500 | `180` | preview truncation | | `testPing` | integer (epoch ms, `0`) | `0` | write-only trigger: when the value changes the host posts a test toast | Composition config (`cordis.patch.yml`) is the settings `base` layer. The user layer overrides it and the Settings → Plugins card edits the user layer. Host side (no `@deepseek-ai/*` imports are possible from a linked package — the plugin must be dependency-free Node ESM): - `ctx.get('settings')` → if present, `ctx.settings.installSection(ctx, 'windows-notification', schema, baseConfig, hooks)`. If the service is absent, fall back to the row config and keep working. - `schema` must be a callable `(value) => resolvedValue` with `.toJSON()` returning a serialized Schemastery envelope (the client rehydrates and validates with it). Generate the envelope offline with the real Schemastery package and embed it as a JS literal; do not import it at runtime. - Real Schemastery lives at `/@deepseek-ai/dsh/node_modules/@deepseek-ai/schemastery` and is importable from a scratch `.mjs` script with an absolute `file:///…` specifier or by running node from that package's directory. Generate, then hard-code the result. - Read current value via the `installSection` hooks source; `onChange` fires on every committed change and on attach/detach. React to it: update config, and when `testPing` changes post a test toast. Useful local API references (read-only; these are the installed runtime packages, not the plugin's imports): - `~/.dsh/profiles/node_modules/@deepseek-ai/` — 230+ packages with `.d.ts` and README (absolute path `~/.dsh/profiles/node_modules/@deepseek-ai`). - Settings service: `dsh-settings` (`lib/types/index.d.ts`, `lib/index.js`). - Session events: `dsh-session/lib/types/types.d.ts` (`SessionEventMap`, `TurnEndReasonMap`, `session/event` signature in `lib/types/index.d.ts`). - Approval: `dsh-user-approval/lib/types/types.d.ts` (`approval/request` waterfall, `ApprovalOutcome`), `lib/types/index.d.ts`. - Tool registration: `dsh-tools/README.md` + `lib/types/*.d.ts`; an existing plugin can register a raw definition object with `ctx.tools.register({ name, description, parameters, output, execute })` (confirm the exact schema shape against `dsh-ops` / `dsh-tools` sources before relying on it). - Session title: `ctx.get('sessionTitle')?.get(session)` (see `dsh-session-title`), fall back to session id. - Session metadata: `session.meta` / `session.header.meta` for `cwd`, `origin`, `delegationDepth` (verify exact property path); skip subagent sessions (`origin === 'subagent'` or `delegationDepth > 0`). ## PowerShell CLI contracts Windows PowerShell 5.1 only (do not require pwsh 7). All scripts are UTF-8 files; host passes/reads UTF-8. ### toast.ps1 ``` toast.ps1 -CheckOnly → stdout: {"ok":true,"foreground":true|false,"dshPid":} foreground = the foreground window belongs to a running "DeepSeek Harness" process (any window). Must finish in < 2s. toast.ps1 -XmlBase64 [-Aumid com.deepseek.dsh] [-Tag ] [-SkipWhenForeground] [-Priority high|default] → stdout: {"ok":true,"shown":true|false,"reason":"foreground"|"shown"|…,"foreground":bool} Decode UTF-8 base64 → XmlDocument → ToastNotification → Show(). -Priority high sets ToastNotification.Priority = High (priority notifications may break through Do Not Disturb when the user allows DSH as a priority app; default stays "default"; ignore failure). -SkipWhenForeground: run the same foreground check first and skip Show() when DSH is foreground (reason "foreground"); still print valid JSON. Always exit 0 when a JSON line was printed; print JSON on errors too ({"ok":false,"error":"…"}). Never write extra non-JSON lines to stdout; diagnostics go to stderr or the log file. ``` ### setup-protocol.ps1 ``` setup-protocol.ps1 -HandlerPath [-LaunchTarget ] Idempotently registers HKCU\Software\Classes\dsh-notify: (default) value = "URL:DeepSeek Harness Notification" (or a localizable text) "URL Protocol" = "" shell\open\command = "\"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe\" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File \"\" \"%1\"" Also stores the DSH executable launch target under HKCU\Software\Classes\dsh-notify\dshLaunchTarget (string) for notify-handler.ps1's "app not running" fallback. Prints one JSON line {"ok":true,"registered":true|false}. Does not need admin. Idempotent. ``` ### notify-handler.ps1 ``` notify-handler.ps1 uri examples: dsh-notify://open?session= dsh-notify://decision?action=approve&approval=&session= Steps: 1. Parse the URI robustly (PowerShell receives it as $args[0]; strip quotes). Query values are URI-decoded. 2. Read %LOCALAPPDATA%\dsh-windows-notification\bridge.json {port,token}; POST {"token", "action":"open"|"approve"|"deny", "sessionId", "approvalId"} to http://127.0.0.1:/action with 2.5s timeout via Invoke-RestMethod/HttpClient. Ignore/log failures; never block > 3s. 3. For open (and by default for decision too, unless action says otherwise): bring the DSH window forward: find a "DeepSeek Harness" process with a main window, ShowWindow(SW_RESTORE), SetForegroundWindow. If DSH is not running, start the launch target stored by setup-protocol.ps1. 4. Append one line to %LOCALAPPDATA%\dsh-windows-notification\handler.log (bounded to ~64 KB) and exit 0. No dialogs, no visible console (the registered command uses -WindowStyle Hidden). ``` The bridge JSON file format is owned by the host: `{"port":,"token":"","pid":,"updatedAt":}`. The handler must tolerate a missing/stale file. Server bind failure is not fatal to the plugin. ## Host behaviour details (index.js) Entry shape (cordis loader row plugin, no build step): ```js export const name = 'dsh-windows-notification' export function apply(ctx, config) { /* … */ } ``` - Guard `process.platform !== 'win32'`: log once and return (no tools/settings registered). - Resolve plugin dir from `import.meta.url`; PowerShell path from `%SystemRoot%`. - Never throw out of event listeners; catch and log via `ctx.logger?.warn/error` (or console). - Spawn PowerShell with `child_process.spawn`, `windowsHide: true`, capture stdout/stderr, timeout kill. Serialize toast shows (a small queue) so bursts do not spawn dozens of processes; different approvals may still wait concurrently on their own decisions. - Foreground check: call toast.ps1 -CheckOnly; cache the result for ~750 ms; on any failure treat as "not foreground" for completion notifications (safer to notify) but "abort claiming" for approvals (do not claim when the check is unknown; call next()). - `turn/end` handling: - reason kind `completed`: if settings.enabled && onTurnComplete → content: header "DeepSeek Harness" + session title (ctx.get('sessionTitle')?.get(session)) + assistant preview (last assistant message text from the session events you observed; truncate to maxPreviewChars; include only when includePreview). - reason kind `error`: if enabled && onError → body with the error message (truncated). - Skip subagent sessions (origin/delegationDepth). Skip when skipWhenForeground and DSH is foreground (pass -SkipWhenForeground to toast.ps1 for the completion/error path). - De-duplicate per session+turn: remember the last turn number notified. - Approval handling (prepend listener): - Conditions: win32, settings.enabled && onApproval && approvalActions, bridge is listening. - Foreground check: if DSH is foreground OR check failed → `return next()` (in-app approval UI is the right surface). - Else claim: create approvalId, show toast with 允许一次/拒绝/打开 DSH buttons and a body naming the tool and the asker's reason; wait for the bridge decision / timeout / request abort. - decision `allow` → return `'allowed-once'`; decision `deny` → return `'rejected'`. - timeout → log and `return next()` so the browser UI asks normally. - `request.signal` abort → return `'cancelled'`. - Never answer a request whose approvalId does not match the pending map; tokens are required. - Tool `windows_notification`: the model may post one custom toast (params: title, body, actions?: 'none'|'open'|'dismiss'; max ~200 chars each). Register it only if `ctx.tools` exists and registration does not throw; keep the definition conservative. Confirm the raw definition shape from `dsh-tools` types / dsh-ops before implementing. - Bridge: `node:http` server on `127.0.0.1`, port 0 (random), 32-byte hex token, POST /action only, JSON body limit 4 KB, 401 on bad token, 404 otherwise. Write bridge.json atomically (tmp+rename) on listen; delete on dispose (`ctx.effect` teardown). Only start when win32 + enabled (or start lazily on first use). - On `apply`, when enabled: register protocol handler once via setup-protocol.ps1 (fire and forget, log failure), and post a "Windows 通知已连接" toast? No — do not spam on every boot. Only post when settings enable toggles on or when testPing changes. ## Client behaviour details (client.js) The browser bundle uses the platform's module-loader wrapper (copy the shape from `a sibling plugin client.js`, the `window.__ModuleLoader__.load({ id, factory(require) {…} })` CJS-factory form; `require('react')` is available from the frozen platform module table; no bundler). - Register a card into the keyed slot `settings.plugin.item` with `key: 'windows-notification'`: `ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name, key, inject: () => ({ scope, t }) }, Card))`. Types/patterns to read: `~/.dsh/profiles/node_modules/@deepseek-ai/dsh-client-ui-settings-plugins/lib/client.js` (BashCard / ConfigurablePluginsTab registration around lines 300–430 and 1780–1830), `lib/types/client/slot-contract.d.ts`, and `~/.dsh/profiles/node_modules/@deepseek-ai/dsh-client-ui-settings/lib/types/client/settings-contract.d.ts`. - `const scope = ctx.settingsScope.bind({ namespace: 'windows-notification' })` in `apply`; the card reads `React.useSyncExternalStore(scope.subscribe, scope.getSnapshot)` (bind `this`; `scope.subscribe`/`getSnapshot` are plain methods). Writes use `scope.set(field, value)` / `scope.unset(field)`; show a disabled/unavailable state when `snapshot.status !== 'ready'`. - Card content (bilingual via `ctx.locale.getLocale()` and an inline STRINGS table, like claude-sidebar): header "Windows 通知 / Windows Notification" + one-line description; toggle rows for `enabled`, `onTurnComplete`, `onApproval`, `onError`, `approvalActions`, `skipWhenForeground`, `includePreview`; select for `sound` (默认 / 静音); number input for `approvalTimeoutSec`; buttons: "发送测试通知" writes `testPing = Date.now()` and "恢复默认" unsets every user field. Use inline styles with DSH CSS variables (e.g. `var(--dsw-alias-label-primary, #e7e7e7)`, `var(--dsw-alias-bg-elevated, #232323)`, `var(--dsw-alias-border-secondary, rgba(127,127,127,.28))`) and keep it compact. It is fine to follow the visual language of claude-sidebar's SettingsRow/Switch CSS. - Must never throw when `ctx.settingsScope` / locale / slots are missing: `ctx.slots.inject` handles slots, other lookups use try/catch. - Return `{ inject: ['slots', 'locale', 'settingsScope'], apply }` from the factory. ## Verification requirements All agents: run `node --check ` for syntax (for `client.js`, `node --check` is enough — it is not imported by Node). Actually exercise anything that can be exercised: - toast-engineer: send a real toast, confirm WinRT history entry and registry stamp for `com.deepseek.dsh`; test `-CheckOnly` JSON; register the protocol and launch a fake URI from the registry command to prove the handler runs and can focus DSH; prove the bridge POST path with a tiny local HTTP listener. Screenshots under `.verify/notification/toast/` when useful. - host-engineer: a fake-ctx harness in `.verify/notification/host/sim.mjs` that loads `index.js`, drives `session/event` / `approval/request` handlers, and asserts the spawned PowerShell invocations / bridge decisions. Also boot a copy of the shipped `web` profile with the plugin linked (see below) if you need a live host; ask the Lead before touching profiles. - client-engineer: static sanity + optionally use the Lead's test web instance (`http://127.0.0.1:6123`) once it is up; the card must render in Settings → Plugins → Plugin configuration. - Report concisely to the Lead with: files, what was verified and how, remaining risks, and exact commands the Lead should run for final verification. ## Test profile recipe (Lead owns; do not run by teammates) A copy of `~/.dsh/profiles/web` named `notify-test`, with `"dsh-windows-notification": "link:C:/plugins/dsh-windows-notification"` added to dependencies and `dsh-windows-notification` appended to `dsh.profile.bundles`. Run with `dsh --profile notify-test web --port 6123`. Never run two instances of the same profile. ## Hard rules - No network installs, no new npm dependencies, no `@deepseek-ai/*` runtime imports from the plugin. - Everything must keep working when Windows notifications are disabled/DND is on: the plugin logs, the harness never blocks, approvals always fall back to the in-app prompt. - Never log or expose the bridge token outside `%LOCALAPPDATA%`. - Do not modify `~/.dsh/profiles/**` or any file outside your ownership. --- # v1.1 additions — user questions + demo notifications The user asked for **input-submission notifications**: when the agent asks the user a question (`user-questions/request`), the Windows toast must offer the question's **options as buttons** (and a free-text input when there are no options), and answering from the toast must resume the agent. The settings card must offer a **demo option** (a selectable demo kind) plus a "发送演示通知" button. ## New settings fields (schema + card + row config base) | field | type | default | meaning | |---|---|---|---| | `onUserQuestion` | boolean | `true` | notify when the agent asks a question | | `questionActions` | boolean | `true` | claim a single-select question while DSH is not foreground and answer from the toast | | `questionTimeoutSec` | integer 10–600 | `60` | fall back to the in-app question UI after this many seconds | | `demoKind` | `'complete' \| 'approval' \| 'question-options' \| 'question-text'` | `'question-options'` | which notification the demo button fires (this is the "演示选项") | | `demoPing` | integer (epoch ms, `0`) | `0` | write-only trigger: when it changes the host posts the demo notification | ## New protocol URIs (host builds the XML; handler parses, never rewrites) ``` dsh-notify://question?action=option&question=&choice=&session=&focus=0 dsh-notify://question?action=text&question=&session=&focus=0 (+ input id "reply") dsh-notify://question?action=open&question=&session= dsh-notify://demo?kind=question-options&choice=&focus=0 dsh-notify://demo?kind=approval&decision=approve|deny&focus=0 dsh-notify://demo?kind=question-text&focus=0 ``` `notify-handler.ps1` must POST the extra fields to the bridge: ```json {"token":"…","action":"answer"|"demo"|"open"|"approve"|"deny","sessionId":"…","approvalId":"…", "questionId":"…","choice":"…","text":"…","kind":"…"} ``` - `action=option` → `action:"answer"` + `choice`; `action=text` → `action:"answer"` + `text`; for a text toast the Windows input value arrives appended to the URI (e.g. `&reply=hello`); accept `reply`, `text`, `input`, `answer` query names as the input value. - `focus=0` means do not steal focus (answers/demos); `open` still focuses. - Handler stays Windows PowerShell 5.1, one JSON line on stdout, exit 0, 2.5 s bridge budget. ## Host behaviour `ctx.on('user-questions/request', async (request, next) => …, { prepend: true })`: - claim only when: win32, enabled && onUserQuestion && questionActions, bridge listening, **exactly one question**, `multiSelect !== true`, and DSH is not foreground (foreground check unknown → do not claim); - options present → toast with up to three option buttons + `打开 DSH`; wait for bridge `action:"answer"` with `choice` / bridge `"open"` / timeout / request abort; answer = `{ answers: [{ id: q.id, selected: [label] }] }`; open/timeout/abort → `return next()`; - no options → toast with `` inside `` and a `发送` action (`hint-inputId="reply"`); bridge `answer` with `text` → `{ answers: [{ id: q.id, selected: [], custom: text }] }`; empty text / timeout / abort → `next()`; - multi-question or multiSelect → notify-only toast (`打开 DSH`) then `next()` (never claim); - de-duplicate by pending question token; teardown releases pending questions via `next()`. Demo (`demoPing` change): post a demo toast per `demoKind` (sample payloads; never suppressed by the foreground check). Approval/question demo buttons use the `dsh-notify://demo…` URIs; on the bridge `action:"demo"` the host posts a follow-up confirmation toast (e.g. `演示成功:你选择了「方案 B」`), proving the round trip without creating a real approval/question. ## Client card additions - 通知时机: toggle `onUserQuestion`. - 通知行为: toggle `questionActions`; number `questionTimeoutSec` (10–600). - 新分组「演示」: `