Smart-DSH — Mobile UI, notifications, and Esc-to-stop for DeepSeek Harness
An unofficial DSH plugin bundle and Linux setup guide, not a fork of DSH. Keep upstream DSH installed; add Smart-DSH for:
- Mobile UI: full-width chat and composer, logo opens the session list, no logo tooltip or tap tint.
- Notifications: questions and top-level turn-end notifications through Web Push.
- Esc to stop: press Escape to cancel the running turn, switchable from Settings → General.
- Remote access guide: connect a phone using tailnet-only Tailscale Serve HTTPS.
- Multi-tab reliability: an optional, guarded shared-HMR workaround prevents upstream developer-update connections from exhausting Firefox's HTTP/1.1 connection slots.
This is an unofficial community extension; it is not affiliated with DeepSeek.
Compatibility is tested against DSH 0.1.2-rc.1; mobile styles use version-specific
selectors. Turn-end notifications describe agent turn termination, not independent
verification that every requested task succeeded.
A self-contained DeepSeek Harness (DSH)
bundle (dsh-notify-push) plus setup notes for the paired remote-access infrastructure
(Tailscale Serve + phone). When the agent calls ask_user_question, your phone receives
a Web Push notification with the question text — even when no browser is connected.
When a turn ends, you get a completion notification whose title reflects the end reason
(作業完了 / token-cap truncation / blocked / error / aborted); subagent turns do not
notify — only top-level sessions. Tapping a notification focuses the app.
A second, dependency-free bundle (dsh-esc-stop) adds the keyboard gesture the
composer's stop button already performs: Escape cancels the turn that is running now.
Menus, dialogs, the queue-message editor, and IME composition keep their own Escape,
and the whole gesture has one ON/OFF row in Settings → General — no file editing and no
restart to toggle it. See Esc-to-stop.
Status: working setup on Arch Linux, verified 2026-09-07 with real deliveries to Android Chrome and desktop Firefox. Host-specific identifiers are omitted from these setup examples.
Requirements
| Requirement | Why |
|---|---|
DSH 0.1.2-rc.1 |
The bundle relies on webServer.register, connection.requestRejection, and the { prepend: true } listener option — verify these exist if your DSH differs (dsh --version) |
Node >= 22.19 (23 excluded) |
DSH's own requirement |
pnpm |
dsh plugin is a thin pnpm forwarder |
Any Chromium-based browser or Firefox with PushManager |
Verified on Android Chrome (FCM) and desktop Firefox (Mozilla autopush) |
| A secure context for the phone | HTTPS via Tailscale Serve (below) — plain http://<ip>:3080 cannot subscribe to push |
| Android: nothing extra. iOS: Add-to-Home-Screen | iOS WebKit only delivers push to installed PWAs (16.4+) |
How it works
ask_user_question (tool)
└─ host: ctx.on("user-questions/request", listener, { prepend: true }) ← outermost
├─ sendPushToAll() web-push → FCM / Mozilla autopush (fire-and-forget)
└─ return next() delegates to api-remotes forwarder → browser composer
(the waterfall is NEVER consumed)
turn completion (top-level sessions only)
└─ host: ctx.inject(["agents"]) → agentCtx.on("session/event", listener)
├─ filter: agents.roots().includes(agents.get(session.id)) ← subagent turns excluded
└─ sendPushToAll(buildTurnEndPayload(session.id, event.data.reason))
titles by reason.kind: completed / max-tokens / blocked / error / aborted
| Component | File | Role |
|---|---|---|
| Host half | dsh-notify-push/lib/index.js |
prepended user-questions/request waterfall listener + session/event (turn/end) listener (root sessions only) + Web Push fan-out + HTTP routes (/api/push/* authed via connection.requestRejection, /push/sw.js static with Service-Worker-Allowed: /) |
| Client half | dsh-notify-push/lib/client.js |
window.__ModuleLoader__.load({...}) wrapper; local Notification while the page is alive; /notify popupSelect command for permission + push subscribe/unsubscribe |
| Service worker | dsh-notify-push/sw/sw.js |
push → showNotification (requireInteraction: true), notificationclick → focus/open window |
| State | $DSH_HOME/notify-push/ (0600, not in this repo) |
vapid.json (generated on first start, must persist across restarts) + subscriptions.json (auto-pruned on 404/410) |
The state directory follows DSH_HOME (default ~/.dsh), so nothing here is
hard-wired to a specific user.
Install
# 0. Where to keep the bundle source — any persistent path works; ~/.dsh/profiles/web/bundles-src/
# is just what this setup uses. Adjust BUNDLE_SRC freely.
BUNDLE_SRC="$HOME/.dsh/profiles/web/bundles-src/dsh-notify-push"
git clone https://github.com/hikarioyama/Smart-DSH.git /tmp/Smart-DSH
mkdir -p "$(dirname "$BUNDLE_SRC")"
cp -r /tmp/Smart-DSH/dsh-notify-push "$BUNDLE_SRC"
# 1. The bundle's own dependency ("web-push"). Order relative to step 2 does not matter,
# but run this BEFORE the first restart.
cd "$BUNDLE_SRC" && pnpm add web-push@^3.6.7
# 2. Register into the web profile (adds the dependency as link: AND appends
# dsh-notify-push to dsh.profile.bundles via its dsh.bundle.patch declaration)
cd ~/.dsh/profiles/web && dsh plugin --profile web add "$BUNDLE_SRC"
# 3. Verify composition read-only (never touches a running server)
dsh --profile web --dump-config | grep dsh-notify-push # expect: "- id: dsh-notify-push"
# 4. Verify the dependency resolves (from the profile dir)
cd ~/.dsh/profiles/web && node --input-type=module -e "await import('web-push'); console.log('web-push resolvable')"
Dependency note:
dshprofiles usenodeLinker: hoistedin theirpnpm-workspace.yaml, so thepnpm addin step 1 lands inside the profile'snode_modulesand resolves from the linked bundle. If step 4 reports thatweb-pushcan't be resolved, re-run step 1 and thenpnpm installin the profile dir.
Then restart and enable:
systemctl --user restart dsh-web.service
# In the DSH UI on each device: /notify → ON → grant the notification permission.
Expected result: ~/.dsh/notify-push/vapid.json + subscriptions.json appear on first
start; each enabled device appears as one subscription; asking the agent a question that
triggers ask_user_question produces a notification on every enabled device.
Fresh tabs show “No sessions yet”
On DSH 0.1.2-rc.1, upstream client-hmr opens a permanent developer-update
connection for each tab. Enough tabs can exhaust a browser's HTTP/1.1 slots,
preventing fresh session-list requests and WebSocket handshakes. This is unrelated
to the dsh-notify-push notification plugin and does not mean sessions were deleted.
Smart-DSH includes a version- and checksum-guarded workaround that shares one HMR connection across tabs. From this checkout:
node scripts/apply-shared-hmr.cjs --check # read-only; resolves the dsh on PATH
node scripts/apply-shared-hmr.cjs --apply # explicit write with a private backup
node --test scripts/test-shared-hmr.cjs scripts/test-apply-shared-hmr.cjs
It does not restart DSH or alter browser preferences/session data. Unknown versions
or existing local edits are rejected. This is a separate, optional install step;
copying only dsh-notify-push does not apply it. See the
workaround guide for explicit paths,
rollback, browser requirements, live-update limitations, and rechecking after DSH
upgrades.
Esc-to-stop (dsh-esc-stop)
Press Escape while the agent is working and the running turn is cancelled — the same
cancellation the composer's 停止生成 button performs. ON by default, with one toggle
in Settings → General → Esc で推論を停止; the value is the host setting
esc-stop.enabled in the user settings document, so it follows the account across
devices and toggling needs no file edit or restart.
Escape is a shared key, so the listener refuses whenever a nearer surface owns it:
| Refusal | Why |
|---|---|
Ctrl/Meta/Alt/Shift held, or a key repeat |
Browser/OS chords, and holding the key must not fire repeatedly |
| IME composition | The input method owns the key |
event.defaultPrevented |
A handler that already consumed Escape wins |
the target is a native input/textarea/select |
The queue-message editor closes itself on Escape |
an [aria-modal="true"] element is open |
The Settings panel owns Escape while it is open |
a [role="listbox"]/[role="menu"] element is open |
The command menu, a popupSelect, or a picker owns Escape |
| the toggle is OFF, nothing runs, the session was removed, or a subagent is on stage | Nothing to stop, and the button itself is absent in those states |
The listener is capture-phase on document with a one-microtask deferred judgement, so
Escape never closes a menu and stops the turn: a nearer handler's consumption, or the
overlay it just closed, is read exactly once — after the key has been routed.
The accepted path is the button's own (sessions.scope(id).get("conversation").cancel()),
so failure presentation is identical: the message lands in the session's promptError.
Stop cancels the in-flight turn only — queued messages stay and resume in FIFO order.
Install is the same shape as the dsh-notify-push steps with dsh-esc-stop substituted,
plus one extra registration step (the bundle has no runtime dependency of its own, so the
extra pnpm add step does not apply). dsh plugin only forwards to pnpm and the profile
is a pnpm workspace root, so the dependency needs -w; the layer list is edited directly:
BUNDLE_SRC="$HOME/.dsh/profiles/web/bundles-src/dsh-esc-stop"
git clone https://github.com/hikarioyama/Smart-DSH.git /tmp/Smart-DSH
mkdir -p "$(dirname "$BUNDLE_SRC")" && cp -r /tmp/Smart-DSH/dsh-esc-stop "$BUNDLE_SRC"
cd ~/.dsh/profiles/web && dsh plugin --profile web add "$BUNDLE_SRC" -w
node -e 'const fs=require("fs"),p=process.env.HOME+"/.dsh/profiles/web/package.json",m=JSON.parse(fs.readFileSync(p,"utf8")),b=m.dsh.profile.bundles;if(!b.includes("dsh-esc-stop"))b.push("dsh-esc-stop");fs.writeFileSync(p,JSON.stringify(m,null,2)+"\n")'
dsh --profile web --dump-config | grep dsh-esc-stop # read-only composition check
systemctl --user restart dsh-web.service # never from the session being restarted
Until that restart the running server keeps its boot-time composition: the settings row and the listener appear only afterwards.
Details, guard-by-guard rationale, and limitations: dsh-esc-stop/README.md.
Paired infrastructure (remote access + phone)
Minimal, reproducible form — the flock/guard/URL-file plumbing in the author's setup
is machine-specific and intentionally not part of this repo:
# ~/.config/systemd/user/dsh-web.service (minimal working form)
# Adjust the ExecStart path to your dsh install location (`which dsh`).
[Unit]
Description=DSH web server
After=network.target
[Service]
Environment="DSH_HOME=%h/.dsh"
ExecStart=%h/.local/bin/dsh web --host 127.0.0.1 --port 3080 \
--trusted-host <machine>.<tailnet>.ts.net --no-open
Restart=on-failure
[Install]
WantedBy=default.target
# Expose to the tailnet only (current tailscale CLI syntax; run once, persists):
tailscale serve --bg 3080
# → https://<machine>.<tailnet>.ts.net (proxying http://127.0.0.1:3080)
tailscale serve status # verify
Notes learned during setup:
- DSH binds loopback only; Tailscale Serve is the sole external exposure, so the
unauthenticated
/push/sw.jsroute is reachable from tailnet devices only. - The
--trusted-hostvalue must match the hostname your phone uses, or the browser-trust fence will reject the connection. - The web app already ships a PWA manifest, so the notification-click focus path works from a home-screen install too.
- If you also run a guard against duplicate DSH launchers, do not boot a second throwaway instance for testing — see "Testing without a live server".
tailscale servesyntax differs across versions (serve --bg 3080on current CLI,serve https / http://...on older ones) — checktailscale serve --help.
Testing without a live server
A self-contained node --test suite ships with the bundle (writes only to a temp
DSH_HOME, touches no real state):
cd dsh-notify-push && pnpm install && npm test
# expect: pass 1 / fail 0
What it covers:
- Host half with a fake
ctx(ctx.on/effect/inject+webServer.registercollector +Readable.fromrequest bodies): route registration, auth 401/403 on/api/push/*, subscription validation + persistence, waterfall delegation (next()value passes through), and theService-Worker-Allowed: /header on/push/sw.js. - The push send path is exercised with a real ECDH P-256 subscription: encryption + VAPID
signing succeed and the send fails at DNS (
ENOTFOUNDagainst an invalid endpoint), which proves the pipeline up to the network. - The client half is not covered by an automated test: verify it manually by loading
lib/client.jswith awindow.__ModuleLoader__shim and a stubctx(assert theuser-questions/requestlistener passesnext()'s value through, and that the/notifypopupSelect contribution registers).
dsh-esc-stop ships its own suite, which does cover the client half through the same
ModuleLoader shim (decision guards, listener wiring, disposal, and the settings row):
cd dsh-esc-stop && npm install && npm test
# expect: tests 14 / pass 14 / fail 0
Ops
- Toggle:
/notifyON/OFF per device. Revoking browser permission → auto re-subscribe fails at the next startup and the stored toggle drops to OFF. - Restart checklist: DSH sessions survive via the append-only log; verify
$DSH_HOME/notify-push/vapid.jsonstill exists after restart (fresh keys would orphan every subscription — deletesubscriptions.jsontoo if you intentionally reset keys). - Not receiving notifications, in order: (1)
vapid.jsonsurvived the restart, (2) nonotify-pusherrors in the server log, (3) the site's notification permission is "Allow" and Chrome's system-level notifications are on, (4)subscriptions.jsonstill has entries. - This bundle follows the
dsh.bundle.patch+dsh.clientthree-layer plugin pattern; seedsh-notify-push/cordis.patch.ymlandpackage.jsonfor the minimal declarations.
Customization pointers
- Language: the notification title/body prefix and the
/notifycommand copy are Japanese by default (選択肢,(他 N 件),通知: ON). EditbuildPayloadinlib/index.js,questionSummary/statusLabel/command labels inlib/client.js. - VAPID subject:
mailto:root@localhostinconfigure()is a placeholder; some push services warn about it — set your own contact address. - Browser support: any browser exposing
PushManagerpasses the/notifyavailability check; only Android Chrome and desktop Firefox have been verified. - The
requireInteraction: trueinsw/sw.jskeeps the notification on screen; lower it if you prefer transient banners.
KG node
The knowledge-graph node mirroring this repo:
~/knowledge/nodes/agents/dsh-notify-push-bundle-pattern.md.
Mobile layout (DSH 0.1.2-rc.1)
At viewport widths up to 767 CSS pixels, the collapsed sidebar occupies only the
logo corner; the chat column uses the full viewport width. Tap the DeepSeek logo
to open the session list in one step, not the intermediate icon rail. Question
and plan cards use the column width (100%) instead of the 680px content floor,
with 2.5% side padding, so a narrow phone does not clip either edge. Desktop
layout is unchanged.
Upstream expanded sidebar panels retain their normal behavior. CSS-module selectors
are version-specific: recheck after a DSH upgrade. No notification logic is changed.
Browser regression check against an existing DSH server (does not start/restart DSH):
PLAYWRIGHT_MODULE=/path/to/playwright DSH_LOGIN_URL_FILE=/path/to/private-login-url.txt node scripts/test-mobile-layout.cjs.
Uses isolated browser contexts, verifies 360/412/767/768/1280 CSS-pixel widths, toggle
round trips and cleanup. The login URL file must be private; never commit it.
Esc-to-stop regression check
scripts/test-esc-stop.cjs drives the shipped dsh-esc-stop listener inside a live DSH
page (same private login URL input, no DSH restart):
PLAYWRIGHT_MODULE=/path/to/playwright \
DSH_LOGIN_URL_FILE=/path/to/private-login-url.txt \
node scripts/test-esc-stop.cjs
It asserts that the composed provider bundles (dsh-api-session-controller,
dsh-client-ui-renderer, dsh-client-ui-settings) are present, then dispatches real
KeyboardEvents against stub sessions — one cancel on a running turn, and no cancel for
idle, toggle-off, key repeat, modifier chords, an open modal, an open list overlay, a
focused native input, an already-consumed key, disposal, and the named refusal reason. Stub sessions mean no real
turn is cancelled; the whole bundle is also loaded into the page to prove it parses and
registers with __ModuleLoader__. The login URL file must be private; never commit it.
No comments yet. Be the first to write one.