Skip to content
dsh.fish
Bundle

dsh-work-done-bell

Rings a Windows system sound (and shows a bottom-right toast) when a DeepSeek Harness session has actually finished all of its work — idle plus no live background job and no running subagent.

Source
FunctionHookTJU
License
MIT
Updated
Updated 6 days ago

Readme

# dsh-work-done-bell

**English** | [简体中文](README.zh-CN.md)

A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that
rings a **Windows system sound** when a session has *actually finished*, and
shows a **bottom-right toast** naming the session that finished. A persistent
bottom-right switch turns both off, and that preference is stored in a real
settings namespace, so it survives a restart.

## Why "idle" is not "done"

`agent/status -> idle` means only that the agent's driver has nothing scheduled.
Work routinely outlives it:

- a `run_in_background` job keeps running and its settlement opens **another**
  turn, and
- a delegated subagent keeps running while its parent sits idle.

Chiming on raw `idle` therefore rings two or three times per task. This plugin
waits for **quiescence**: `idle` *and* no `running`/`stopping` job owned by the
session *and* no running agent owned by it. A 90-second deadline bounds that
wait, so a long build cannot swallow the notification; once the deadline has
rung, the later true quiescence stays silent for the same episode.

## What it sounds like

The reason comes from the durable session log (`turn/end`), not from a guess:

| `turn/end` reason | Sound | Toast |
| --- | --- | --- |
| `completed` | `Asterisk` (default notification) | yes |
| `blocked` (a plugin rejected the step) | `Asterisk` | yes |
| `max-tokens` (output ceiling hit) | `Asterisk` | yes |
| `error` | `Hand` (critical stop) | yes |
| `aborted` — including your own Esc/Stop | silent | no |
| `interrupted` (crash-orphaned turn) | silent | no |

Simultaneous completions within 2.5 s collapse into one sound, so five sessions
finishing together do not stack five players.

## Install

Clone the repository, then install it from the directory you cloned into:

```sh
git clone https://github.com/FunctionHookTJU/dsh-work-done-bell
dsh plugin --profile web add file:/absolute/path/to/dsh-work-done-bell
```

then add the package name to the profile's bundle list, in
`<DSH_HOME>/profiles/web/package.json`:

```json
{
  "dsh": {
    "profile": {
      "bundles": ["@deepseek-ai/dsh-base", "...", "dsh-work-done-bell"]
    }
  }
}
```

(`dsh plugin add` reconciles that list itself when the installed package
declares `dsh.bundle`. Restart DSH afterwards; nothing else in the composition
needs editing.)

> **Use `file:`, not `link:`.** The plugin resolves
> `@deepseek-ai/schemastery` by walking up from its own directory into the
> profile's `node_modules`. A `link:` install keeps the real path outside the
> profile and the import fails at load.

## Uninstall

```sh
dsh plugin --profile web remove dsh-work-done-bell
```

Remove it from `dsh.profile.bundles` (which `dsh plugin remove` also reconciles),
then restart. The `work-done-bell` namespace in `settings.yaml` is inert once
the plugin is gone and can be deleted by hand.

## The switch

Bottom-right, always visible: *完成提示已开 / 完成提示已关*. It writes
`work-done-bell.enabled` through the settings service, and both halves read it —
the host half stops the sound, the browser half stops the toast. The state lives
in `<DSH_HOME>/settings.yaml`, so it survives a restart.

## Introspection

There is no model-facing tool, on purpose: `@deepseek-ai/dsh-tools` is not
present in the profile, and a plugin may only import what the profile already
carries (ESM resolution ignores `NODE_PATH`). The state route carries the same
information instead:

```sh
curl "http://127.0.0.1:3080/dsh-work-done-bell/state?verbose=1"
```

That returns tracked statuses, the last `turn/end` reason per session, open
quiescence episodes with their deadlines, the last sound exit code, and any
error the plugin swallowed. Without `?verbose=1` it returns only what the
browser half needs.

## Requirements and known limits

- **Windows only** for the sound: it plays through
  `[System.Media.SystemSounds]`, executed via the composed `shell` service. On
  other platforms the plugin loads and toasts, but stays silent. Porting it
  means changing the command in `play()` to `afplay` / `paplay`.
- **The sound command carries a resolved sandbox policy.** A confining shell
  service refuses a command that arrives without one, and the deployment
  default may be a mode the host cannot enforce at all — on the machine this
  was built on, `workspace-write` fails because the Windows ACL runner wants a
  temp root outside the workspace while the workspace is the user profile, so
  *nothing* runs confined and an unpolicied command is simply refused. The
  policy is therefore resolved per chime for the session being announced
  (`sandboxPolicy.resolve({ session })`), which is that session's own
  authority, never a widening of it. On such a host a session confined to
  `workspace-write` stays silent; `?verbose=1` reports the reason in
  `lastError` and the resolved mode in `lastPolicy`.
- The toast requires an open page; the sound does not.
- The browser half polls the host every second over
  `/dsh-work-done-bell/state`. There is no push channel: the sanctioned
  direction of package communication is browser -> host.
- Toasts label the session by a short id, not its title.

## Development

Four suites, all runnable with no harness running:

```sh
npm test                           # all four
node test/state-machine.test.mjs   # the decision table, on a manual clock
node test/integration.test.mjs     # the plugin inside a REAL cordis app
node test/client-bundle.test.mjs   # the browser bundle's contract
node test/docs-parity.test.mjs     # the two readmes' shared skeleton
```

`integration.test.mjs` is the one that matters most: it boots a real
`@deepseek-ai/cordis` application with stub services and asserts that `apply`
runs, that `ctx.inject` fires, and that both HTTP routes register. An earlier
version of this plugin read `ctx.get('webServer')` once at apply time and
called `jobs.onJobsChanged` unguarded; it looked healthy while registering
nothing in production, because a host-composition row mounts *during boot*,
before its services exist.

The suites need `@deepseek-ai/cordis`, `@deepseek-ai/cordis-plugin-timer`,
`@deepseek-ai/schemastery` and `@deepseek-ai/cosmokit` resolvable from the
repository. A checkout links them from the harness installation; `node_modules`
is gitignored, so these links stay local:

```powershell
$repo  = (Get-Location).Path   # this checkout
$dsh   = "$env:APPDATA\npm\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai"
$web   = "$env:USERPROFILE\.dsh\profiles\web\node_modules\@deepseek-ai"
New-Item -ItemType Directory -Force -Path "$repo\node_modules\@deepseek-ai" | Out-Null
foreach ($p in 'cordis','cordis-plugin-timer') {
  New-Item -ItemType Junction -Path "$repo\node_modules\@deepseek-ai\$p" -Target "$dsh\$p"
}
foreach ($p in 'schemastery','cosmokit') {
  New-Item -ItemType Junction -Path "$repo\node_modules\@deepseek-ai\$p" -Target "$web\$p"
}
```

`docs-parity.test.mjs` guards the two readmes against drift: the same heading
skeleton, the same code blocks, the same tables, the same inline technical
terms. It cannot judge the prose, but it fails the moment one language grows a
section the other lacks.

## Layout

```
lib/index.js      host half  — quiescence state machine, sound, HTTP route
client/client.js  client half — shell.overlay toast stack and switch
cordis.patch.yml  the composition row this package contributes
test/             decision table, real-cordis integration, client + readme contracts
```

## License

MIT

Install

dsh plugin --profile web add github:FunctionHookTJU/dsh-work-done-bell

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source