# dsh-power-button [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![DSH](https://img.shields.io/badge/DeepSeek-Harness-blue)](https://github.com/deepseek-ai/DeepSeek-Harness) [English](README.md) | [中文](README.zh-CN.md) A self-contained **power & lifecycle controller** for [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness): a sidebar power button with a Restart / Shutdown menu and a full-screen transition overlay. The restart engine is built in — no third-party dependencies. > Developed with DeepSeek AI assistance; reviewed before release. ## Features - **Sidebar power button** in the footer action slot, theme-aware and styled to match the adjacent Settings trigger. - **Restart / Shutdown menu** with a Windows-style full-screen overlay; the page reloads by itself after a confirmed restart. - **Self-contained restart engine**: a detached `.cjs` helper takes over relaunching DSH through an ARM → COMMIT → ACK handshake, waits for the old process, the port and the session logs, then relaunches the same invocation in the same cwd. A `--port 0` command line is pinned to the port the old process actually bound. In the Desktop app the restart is handed to Electron instead, when that app advertises a restart protocol. No PowerShell, no `taskkill`. - **`/restart` and `/shutdown` commands**, a **`restart_harness` model tool** (registration is skipped when another plugin already owns the name), and a read-only **`restart_status`** tool for checking whether a restart happened. - **Localized UI and host notices** (zh / en), following the profile's `locale.preference`. - **Startup housekeeping**: helper logs and scripts, per-restart handshake records, and undelivered notices older than 7 days are pruned from the runtime directory. ## Screenshots **① Sidebar power button** — theme-aware footer entry. ![Power button in the sidebar footer](docs/screenshots/en/power-button.png) **② Restart / Shutdown menu** — two actions, one click away. ![Restart / Shutdown menu](docs/screenshots/en/power-menu.png) **③ Shutdown confirm dialog** — the default focus sits on **Cancel**; only an explicit confirm stops the process. ![Shutdown confirm dialog](docs/screenshots/en/shutdown-confirm.png) **④ Shutdown progress overlay** — shows the current stage while the process winds down. ![Shutdown progress overlay](docs/screenshots/en/shutdown-progress.png) **⑤ Restart completed toast** — after the auto reload, a notice confirms DSH is back. ![Restart completed toast](docs/screenshots/en/restart-done.png) ## Install ```sh dsh plugin --profile web add "github:keyiadiannao/dsh-power-button#master" ``` Restart DSH; a power button appears in the sidebar footer. Requires Node ≥ 22.19. ## Configuration Configured through the profile's cordis layer (`cordis.patch.yml` or the settings UI): | Key | Default | Meaning | |---|---|---| | `enableModelTool` | `true` | Register the `restart_harness` model tool. Set `false` to keep restart on the GUI button and `/restart` only. | | `maxDelayMs` | `5000` | Upper bound (ms) for the model tool's `delayMs`. The effective floor is 1000 ms. | | `restartWakeMode` | `quiet` | What the session that asked for a restart is told afterwards. `quiet` stages the notice for its next step without waking anything; `notify` also wakes that session once it is live, so the model reports the restart without a user message. Neither mode restarts anything on its own. | ```yaml - id: dsh-power-button config: enableModelTool: true ``` ## How it works Restart is an explicit ARM → COMMIT → ACK handshake. The old process does not exit until a live helper owns the relaunch; every failure before the ACK keeps the old process running. After spawning the successor, the helper waits up to 180 seconds for readiness. The browser allows a five-minute polling budget for the full restart, including shutdown and preparation. A refused request remains an error even if later health probes fail. ``` click power → Restart [host] POST /api/dsh-power-button/restart write the per-restart helper .cjs (0600: it embeds the relaunch argv) spawn `node ` detached [helper] ARM → status { stage: 'armed' } [host] wait for ARMED (5 s), then write the COMMIT file for this restartId [helper] ACK → status { stage: 'committed' } [host] only now: unref the helper, flush live sessions (≤5 s), request exit after delayMs with a 15 s watchdog behind it [helper] wait for the old PID to exit, the port to free, the session logs to settle write the v2 marker, then spawn DSH with the same invocation and cwd poll /health until instanceId differs and restart.restartId matches [client] reload ``` Five invariants hold this together: - The helper lives **outside the process tree** (`detached`, a real `.cjs` file — a multi-line `node -e` is mangled by Windows `CreateProcess`), so terminating DSH cannot kill it mid-flight. - Success is confirmed by a per-process `instanceId` that must **change**, so a brief outage never fakes it. - The launcher's exit channel is read at `ctx.get('appExit')`, never as a property: it is an optional host service this plugin does not inject, and a property read resolves to `undefined` — which would skip graceful disposal and hard-kill instead. - Exit is bounded end to end, and the bounds are **derived rather than duplicated**: graceful exit gets a 15 s watchdog, the pre-exit flush cap is 5 s, and the helper's patience for the old PID is computed from both, so they cannot drift into the helper giving up first. - The successor's environment is **re-read from the registry**, not inherited: a process environment is a snapshot from its launcher, so a variable changed with `setx` or System Properties would otherwise never reach the relaunched instance. The block is composed in the order Windows uses — the inherited block as the base layer, machine values over it, then user values shadowing those — and a `REG_EXPAND_SZ` value is resolved against the block as it stands at its own stage, so a machine value cannot see a user-only name. That isolation is between registry layers, not a byte-exact replay of how Windows builds the block: names the launcher already carried stay visible at every stage, which is deliberate because some of them — `SystemRoot` on a real machine — exist only in the process environment. `PATH` keeps both halves (machine first) and each part is expanded according to its own type before duplicates are dropped. It is read through `reg export` (UTF-16LE), so non-ASCII values survive on any console code page; the export is a transient file under the harness home that is deleted in the same call, and it inherits that directory's ACL exactly as `.credentials.yaml` does — the file mode is not a privacy guarantee on Windows. A variable **deleted** from the registry still cannot be seen, and a directory deleted from a registry `PATH` survives while the launcher's block still carries it — it cannot be told apart from one a package manager injected at launch, and dropping those would break children that resolve tools by name. Non-Windows platforms keep plain inheritance. Shutdown posts `/api/dsh-power-button/shutdown` and stops without relaunching. It is irreversible, so the button and `/shutdown` both open a confirm dialog; the model is never given shutdown. ## Desktop The Desktop application runs the Host as a child of its Electron shell, so exiting that child invokes the app's crash recovery and replaying its command line yields a Host without a window or its original IPC channel. Two cases, decided by what the host advertises: - **Host advertises a restart protocol** (`DSH_DESKTOP_RESTART_PROTOCOL` in the Host environment, IPC channel connected): the plugin writes the restart marker, flushes live sessions, and asks the shell to restart; Electron owns the relaunch. If the shell does not take over within 20 seconds, the request fails, the transition is released, and the button works again. - **Host advertises nothing** (every released DeepSeek Harness so far): restart and shutdown are refused with `503` and `reason: "desktop-unsupported"`; no helper or exit is scheduled. Use the application's Quit menu or tray action, then reopen it — closing the main window can leave the app running in the tray. Making the first case available means changing DeepSeek Harness itself, which is outside this plugin's boundary; the steps are in [`docs/desktop-restart-protocol.md`](docs/desktop-restart-protocol.md). ## Safety - Destructive POSTs go through **the deployment's own browser-trust fence and authentication** via `ctx.connection.requestRejection` — the call the host's `/api` route uses. `/api/dsh-power-button/*` is longer than the official `/api` prefix, so longest-prefix matching never routes these requests through that fence; delegating keeps them under the operator's real policy, including LAN authorities derived from an all-interface bind and any `trustedHosts` / `--trusted-host` declaration. - On a host with no connection service (a profile without the web bundle) the plugin falls back to its own rules: `Host` must be a loopback authority or one the host declared, plus the cross-site and full-authority `Origin` checks. That fallback is narrower than the official fence and can only refuse; it does **not** provide browser-session authentication. - A refusal answers with a stable `reason` code and logs the observed `Host` / `Origin` / `sec-fetch-site` / remote address. `host-fence` and `not-authenticated` mean the deployment decided, not this plugin. - An **at-most-once latch** rejects duplicate transitions, so a concurrent second POST gets `409`. - The model tool's `delayMs` is **floored at 1000 ms** — the model cannot kill the process before its own turn settles. - The restart marker is **consumed on boot**, so a later ordinary launch never misreports a restart. Command lines are **redacted** in logs; helper and marker files are written `0600`, the runtime directory `0700`. ## Restart awareness Three audiences, deliberately not one: - **The UI** shows a localized "Restarted" toast. The restart identity stays on `/health` for the process's whole life; acknowledging the toast clears only the pending flag. - **The session that asked** gets a notice shaped by `restartWakeMode` — `Agent.inject()` when `quiet`, `Agent.followup()` when `notify`. Only a restart with a recorded causal session gets one: a GUI click records none and is never attributed to whichever conversation happens to be open. At most one notice per session is queued and it is deleted once delivered. - **Every other session** can call `restart_status`, which reports whether a restart happened, its id, stage, the instances involved, timings, who asked, and whether the running process is the instance that restart produced. The plugin never appends a model-visible event and never forges a turn: awareness is handed to the Agent API, whose `inject()` durably stages an `agent/inbox/spliced` record for the next legitimate step. Delivery is once per notice in normal operation; a crash between the inbox write and the deletion can repeat it, and the notice text is written so a repeat is harmless. `/health` also reports `appExit: "available" | "missing"` — whether the launcher-provided exit channel actually resolves here, which turns a delayed restart into a one-request diagnosis. Both exit paths log the pre-exit flush tally (`live` / `fulfilled` / `rejected` / `timedOut`), so a refused or partial durable write is visible afterwards instead of looking like a clean exit. ## Development ```sh pnpm build # tsdown: host + client bundle (committed to lib/) pnpm typecheck # tsc --noEmit over src pnpm typecheck:tests # the root tsconfig excludes tests/ pnpm test # vitest: unit + process-level acceptance pnpm check # all of the above, in order ``` CI runs both typechecks, tests and build on Windows, Ubuntu and macOS, gates that the committed `lib/` matches a fresh build byte for byte, and checks the `pnpm pack` tarball; a separate Linux job repeats everything on Node **22.19**, the floor `engines.node` declares. `tests/restart-runtime.spec.ts` runs the exact helper the host generates as a real process against real TCP ports - the relaunch chain, no relaunch without COMMIT, bounded spawn retries, a stale instance refused, and the failure diagnoses - and the suite isolates `DSH_HOME`, so it never touches your real `~/.dsh`. ## License & Attribution MIT. The "detached helper relaunch" idea follows [anweat/dsh-restart](https://github.com/anweat/dsh-restart) (MIT); the implementation is independently written (real `.cjs` file, no PowerShell, dynamic port), no code copied. Thanks to [@qq1131909103](https://github.com/qq1131909103): PR #8 established that the Desktop Host belongs to the Electron shell, which is why this plugin refuses power actions there instead of spawning a competing Host, and what `docs/desktop-restart-protocol.md` is built on.