better-noticev4.6.1
merusu/better-notice
dsh plugin --profile web add github:merusu/better-notice#da777c558cd860053e5789e7e93f7d57e66b9620
介绍
Visual + audible session notices for DeepSeek Harness (DSH) Web: one tab seat per hot session, a state glyph on every sidebar row, a sound and a popout picture when a session needs you. Six states, three surfaces, one command to install.
标签
概述
Better Notice
English · 中文
A DeepSeek Harness Web UI plugin that gives every hot session a seat in the conversation tab row, a status glyph in front of its sidebar row, and — when its state changes — a sound and a popout image.
It solves one specific annoyance: the sidebar is a flat recency list, so once you have a dozen sessions you cannot tell which one is waiting for your approval, which one just finished, and which one crashed — you click through them one by one. Better Notice reads the host's own session state (sessions.list, uiSession.sessionStatus) and re-sorts those sessions into six categories, each with its own place, icon, sound and artwork.
Install in one line (then restart the GUI — see Install):
dsh plugin --profile <profile> add @merusu/dsh-better-notice
- Package:
@merusu/dsh-better-notice(module id is the same string) - Version:
4.6.1(see CHANGELOG.md) - License: MIT
- Settings live in account menu → Settings → “Better Notice” (a
settings.sectionentry, idhotlist, order30)
| Who you are | Read |
|---|---|
| I just want it working | Install → Verify → What it does. Defaults are fine; ignore every setting. |
| I want to tune it | Customization index — organized by what you want, not by field name. |
| I am an AI agent | Start with AGENTS.md (what already exists, the three things that break the host, the public API), then If you are an AI agent → Modifying this plugin. Machine-readable one-pager: llms.txt. |
| I want my own sounds | Sounds and the bundled TTS tool. |
What it does
Three surfaces, one shared decision:
- Conversation tab row — each hot session takes one tab seat; click it to switch. The current session does not take a seat (it is the “Chat” tab).
- Left sidebar — one status glyph in front of every session title (emoji by default, or an uploaded square image), optionally animated.
- On state change — one sound plus one popout image that slides in from a window edge.
The glyph can also be your own square picture instead of an emoji:
The twosettings-*.pngscreenshots below were taken on an earlier release. The current page has no size row, no preview once button, and rate is a 0–100 % slider;Hide host status dot,Reset alland the overflow notice were removed in 4.4. The field tables in this file are the authoritative description.
Default behaviour, out of the box
Only three states pop a picture: error, pending, completed. In words: something broke, you have to approve / choose / review, it just finished. pinned, running and idle never pop — an idle session leaning into your screen would be noise, not news. A popout stays for a few seconds and then leaves on its own (popout.displayMs, default 7 s, configurable per category).
The bundled artwork follows colour semantics, and it is the author's placeholder art, not a finished design:
| State | Character | Pose | Why this colour |
|---|---|---|---|
error | red Meru | meru2-0-opinion — leaning out sideways | red reads as warning: something needs you |
pending | red Meru | meru2-0-id — holding up an ID | red again: you have to act |
completed | blue Lan | lan2-0-sleepy | blue reads as calm / settled: done |
Only those three states ship raster art (4 tiers each: 64 / 320 / 780 / 1560 px, .webp). pinned, running and idle have no raster art — their popouts fall back to the packaged SVG and then to the emoji glyph. That is why the package is ~700 KB instead of ~1.6 MB, and it costs nothing functionally: those three do not pop by default anyway.
You are meant to replace all of it. Settings → the category card → Popout → upload image, or window.__HOTLIST__.assets.add(category, 'popout', { name, dataUrl }). Any square transparent PNG/JPG/WebP/GIF works; SVG is rejected.
A note from the author. The art in assets/ is a placeholder. A later version of this repository may ship the complete art-generation skill — the whole pipeline used to make these characters — so that anyone can produce their own set instead of drawing from scratch. If you want custom art but do not know where to start, you may prefer to wait for the next release: it may bring assets and a documented process you can reuse.
Categories at a glance
| Category | When it appears | Tab seat | Sidebar glyph | Popout image | Sound |
|---|---|---|---|---|---|
📌 pinned | You pinned it in the host's own UI (hover/right-click a sidebar row → “Pin session”) | ✅ | ✅ | — | — |
⛔ error | The agent reported an error (api-session/error), expires after 10 min | ✅ | ✅ | ✅ | 🔈 |
🔐 pending | Waiting for your approval / answer / plan review (secondary glyph ❓) | ✅ | ✅ | ✅ | 🔈 |
✅ completed | This plugin saw it finish, within the last 7 min | ✅ | ✅ | ✅ | 🔈 |
⏳ running | Working (includes “a subagent is running, parent idle”) | ✅ | ✅ | — | — |
💤 idle | Idle, and last activity is inside the recent window (default 5 min) | ✅ | ✅ | — | — |
✅ = on by default · — = off by default (popout and sound are opt-in per category) · 🔈 = a bundled sound exists but stays silent until you enable that category in settings.
How a session is classified
A session belongs to exactly one category. The first matching rule, top to bottom, wins:
error > pending > completed > running > pinned > idle
Rationale: an error is a root cause and a pending approval is often its consequence, so the error wins; pinning is just “I want to look at this first”, not “something is happening”, so it ranks last of the active states. This order is not configurable — changing it changes semantics, not looks.
Display order is a different thing and is configurable per category (order, default error 10 / pending 10 / pinned 15 / completed 20 / running 30 / idle 50). error and pending tie by default; ties fall back to the classification priority above, so what you actually see is error → pending → pinned → completed → running → idle.
One mechanism is independent of all surfaces: “just finished” survives a page reload. The host's own completionUnread only lives for 1–3 minutes and is cleared as soon as you open the session, so Better Notice records the running true → not running transition itself (batched writes, 500 ms), and after a reload replays it subject to collect.completedTtlMs and the current workspace. Refreshing the page does not lose your ✅ sessions.
Install
Requires Node 22+ and a working DSH Web profile. Verified against DSH 0.2.2-rc.1.
# Install into a profile (<profile> is your profile name)
dsh plugin --profile <profile> add @merusu/dsh-better-notice
# Restart that profile's GUI — required, see below
dsh --profile <profile>
- Not on the npm registry yet. The registry command above 404s until the first
npm publish. In the meantime install the tarball attached to the latest release:dsh plugin --profile <profile> add C:/path/to/merusu-dsh-better-notice-4.6.1.tgz— same result, same restart. - Use an absolute path, forward slashes when you install from a local directory instead of the registry:
dsh pluginforwards the argument to pnpm inside the profile directory, so a relative path points into the profile. - Installing from a directory uses
link:— code edits need no reinstall.client.jschanges apply on page refresh;index.js/cordis.patch.yml/package.jsonchanges need a GUI restart. - Install from a git checkout when you also want the TTS helper (see Sounds). The npm package's
filesfield only shipsindex.js,client.js,cordis.patch.ymlandassets, sotools/is not in it.
Uninstall:
dsh plugin --profile <profile> remove @merusu/dsh-better-notice
Why a restart is mandatory
This is a Cordis bundle with two halves:
package.json→dsh.bundle.patch→cordis.patch.ymlinserts- insert: [{id: hotlist, name: '@merusu/dsh-better-notice'}]into the profile config.- Host half
index.jsregisters the/hotlist-soundprefix route that serves the bundled.wavfiles. The host's shared asset route only accepts images, so audio would 404 there. - Client half
client.jsis declared throughdsh.client(platformweb,immediately) and registers the tab seats, sidebar glyphs, popout and settings page.
The host route is created during process start-up, so a page refresh is not enough.
Verify
In the GUI's browser console:
window.__HOTLIST_VERSION__→ the version string, e.g.'4.6.1'.Object.keys(window.__HOTLIST__)→['version','settings','assets'].- Settings → the left nav has “Better Notice”, and the page shows a global block plus 6 collapsible category cards.
- Session rows in the sidebar now start with a status glyph.
- No
Failed to load pluginsbanner at the top of the page. If there is one, read the first console error.
Audio route self-check:
await fetch('/hotlist-sound/attention.wav').then(r => [r.status, r.headers.get('content-type')])
// → [200, 'audio/wav']
Usage details
Seats. One seat per hot session in the tab row. How many fit is derived from the real tab-row width, and can be pinned to 1–12 (maxSeats). Overflow is trimmed: error and pending (the two “a human must act” categories) are exempt from the seat limit but capped at limit + 4; pinned also gets priority but has its own cap, pinned.maxSeats (0–12, default 3, 0 = hide pinned seats entirely).
Sidebar glyphs. One marker per row, driven by the same session state and the same settings, classified by sidebarStateOf() rather than classify(). The two functions disagree in two known ways (relative order of running vs completed, and whether the host's completionUnread counts) — see Known limitations item 8.
Popout images. Shown when a session enters error / pending / completed. Only one is on screen at a time: while a higher-priority one is up, lower-priority ones are dropped rather than queued; the same session and category will not pop twice within 10 s. Priority error > pending > completed. The current session never pops, and nothing pops while the page is hidden.
Sounds. Played once when a session enters a category. Three gates: the first round after a cold start only records, the same session+category is deduped (cleared when it leaves), and a global 1500 ms throttle drops late arrivals instead of queueing. Volume is global.
Shortcuts.
| Keys | Action |
|---|---|
Alt+Shift+[ | Previous seat (cycles within the current seat ring, wraps) |
Alt+Shift+] | Next seat |
Alt+Shift+S | Pop the overlay once (off by default; needs shortcuts.overlayEnabled) |
Keys are only handled when the page has focus region, no menu/dialog is open, it is not a long press, not an IME composition, and Ctrl/Cmd is not held. Alt+1..9 direct jump is not implemented — bare Alt+digit is rejected by the host's isWebBindingAllowed, and the third-party dsh-session-hotkeys grabs it in the capture phase.
Customization index
Field reference tables are below; this section is organized by intent. Everything here is editable in Settings → “Better Notice” and applies immediately, no reload. The few rows marked API only have no control yet — use window.__HOTLIST_SETTINGS__.set(...).
Baseline: you do not have to configure anything. Defaults are “6 tab seats + sidebar glyphs + popouts on three categories + all sounds silent”.
Placement — where it shows up
Three surfaces are independent; you can enable one, two, or none (none = the plugin is effectively paused without uninstalling).
| I want… | Field | Values | Default |
|---|---|---|---|
| Only in the conversation tab row | positions.tab | true / false | true |
| Also show a status glyph in the sidebar | positions.sidebar (API only) | true / false | true |
| Put it in the window top bar | positions.topbar | false (not implemented, greyed out in settings) | false |
Per-category placement is a second layer: categories.<id>.surfaces.{tab,sidebar,topbar} decides whether that category may appear on that surface. Settings shows these as read-only chips; change them through the API.
Quantity — how many at once
| I want… | Field | Values | Default |
|---|---|---|---|
| Limit tab seats | maxSeats | 1–12, or 'auto' (fit the real width) | 6 |
| Limit how many pinned sessions get seats | pinned.maxSeats | 0–12 (0 = hide pinned entirely) | 3 |
Time — how long things stay hot
| I want… | Field | Values | Default |
|---|---|---|---|
| Keep a just-finished session in ✅ longer | collect.completedTtlMs | 1–1440 minutes | 7 |
Decide how stale a session may be and still count as idle | collect.recentWindowMinutes | minutes; settings exposes 1–36, the API accepts 0–120 (0 = never show idle) | 5 |
The recent window has two layers on purpose: storage clamps0–120(so scripts can set 90), the settings control only offers1–36. Don't mistake the UI bound for the data bound.
The six categories — each one is configured separately
Category ids: pinned · error · pending · completed · running · idle. Every row below is categories.<id>.<field>, six copies, mutually independent.
| I want… | Field | Values | Default |
|---|---|---|---|
| Turn a whole category off | categories.<id>.enabled | true / false | all six true |
| Decide where that category sits | categories.<id>.order | 1–99 | error 10 / pending 10 / pinned 15 / completed 20 / running 30 / idle 50 |
| Sort within the category | categories.<id>.sortBy | 'time' (recent activity first) / 'manual' (host pin order) | 'time' (pinned = 'manual') |
| Restrict it to some surfaces | categories.<id>.surfaces.{tab,sidebar,topbar} | three booleans | first two true, topbar false |
| Choose where the icon comes from | categories.<id>.glyph.source | 'emoji' / 'upload' / 'popout' | 'emoji' |
| Change the glyph character | categories.<id>.glyph.value | emoji, at most 8 code points | per category, see the table above |
| Use your own square image | categories.<id>.glyph.uploads | up to 6, ≤16 KB each, png/jpeg/webp/gif | [] |
| Change the motion | categories.<id>.glyph.motion | none / spin / pulse / bounce / blink | see reference |
| Animate the title too | categories.<id>.glyph.motionTarget | 'glyph' (icon only) / 'row' (icon + title) | 'row' since 4.6.0 |
| Make it animate less often | categories.<id>.glyph.frequency | 0–1, step 0.05 (0 = never) | 1 |
| Pop an image when this category appears | categories.<id>.popout.enabled | true / false | error / pending / completed = true |
| Built-in artwork or your own | categories.<id>.popout.builtin / .images | true / []; ≤6 images, ≤64 KB each | true / [] |
| How long the popout stays | categories.<id>.popout.displayMs | 2000–20000, rounded to 500 | 7000 |
| Ambient motion while it waits | categories.<id>.popout.ambient | none / breath / float | breath on the popping three |
| How it leaves | categories.<id>.popout.exit | drop / particles / fade / slide-out | drop |
| Which edge it comes from | categories.<id>.popout.side | 'auto' (random each time) / 'left' / 'right' (mirrored) | 'auto' |
| Play a sound for this category | categories.<id>.sound.enabled | true / false | false |
| Built-in cue or your own | categories.<id>.sound.builtin / .uploads | true / []; ≤6, ≤128 KB each | true / [] |
pending has one extra field: categories.pending.glyphSecondary (same shape as glyph, default ❓).
Assets — glyph images, popout art, sounds
Every pool takes at most 6 items: glyph images ≤16 KB, popout art ≤64 KB, sounds ≤128 KB, everything together ≤2 MB, and the source file is capped at 4 MB before encoding. Accepted image types: png, jpeg, webp, gif — SVG is rejected.
Picking is automatic and uses two different rules: glyphs are seeded by identity (same session + same category = same image forever), popout art and sounds are random per event (a fresh pick on every pop and every cue). Full limit table and the three ways to load assets: Assets.
Sounds ship as exactly two files
assets/sounds/attention.wav serves error and pending; assets/sounds/complete.wav serves completed. All three categories default to sound.enabled: false — you must switch a category on to hear anything. To generate your own lines, see Sounds and the bundled TTS tool.
Classification priority is fixed
Top to bottom, first match wins: error > pending > completed > running > pinned > idle. It is deliberately not exposed to users (it changes meaning, not appearance). What is configurable per category is display order (order) — a different thing.
Settings reference
Field-name-level documentation for people writing scripts.
- Collapsed card: glyph preview, category name,
· order N, five read-only chips (Tab / Bar / Top / Pop / Snd — highlighted = on), caret. Click the row to expand; several cards may be open at once. Open state lives insessionStorage(dsh.hotlist.open), never in settings. - Expanded area (compact multi-column): ① glyph (source / upload / value / frequency slider); ② motion (
motion+ targetglyph/row); ③ popout (enabled / image / entrance·ambient·exit / side·display time); ④ sound (enabled / source / preview); ⑤ order. - Global block: seat limit (with “auto”), pinned seats, completed TTL in minutes, global volume, recent window (minutes),
positions.tab,positions.topbar(greyed “coming soon”),takeOverStatusDot. - Out-of-range writes are clamped. A failed write (localStorage quota) is reported in the page footer and through
window.__HOTLIST_SETTINGS__.lastError().
Global defaults
| Setting | Default | Range / notes |
|---|---|---|
maxSeats | 6 | 1–12, or 'auto' (follows the real tab-row width) |
positions.tab | true | master switch for tab seats |
positions.sidebar | true | master switch for the sidebar (no settings row; API only) |
positions.topbar | false | window top bar, not implemented, greyed out |
takeOverStatusDot | true | take over the status dot drawn by other plugins (dsh-sessions-manager) |
hideNativeDot | true | hide the host's native status slot (no settings row since 4.4; field kept) |
collect.completedTtlMs | 420000 (7 min) | 1–1440 minutes |
collect.recentWindowMinutes | 5 | minutes. Settings offers 1–36; API accepts 0–120 (0 = hide idle) |
sound.volume | 60 | 0–100, global |
overlay.side | 'auto' | auto / left / right |
overlay.sizeMin / sizeMax | 260 / 520 | 120–900 px (no settings row since 4.5; midpoint, narrowed to the viewport) |
pinned.maxSeats | 3 | 0–12, 0 = hide pinned; only limits how many — pinning itself comes from the host |
display.reduceMotion | 'auto' | auto / on / off; on freezes all motion |
shortcuts.jumpEnabled / overlayEnabled / overlayKey | true / false / 'Alt+Shift+S' | no settings rows since 4.4; keys keep working |
Per-category fields
| Setting | Default | Notes |
|---|---|---|
enabled | all six true | off = no classification, no seat, no sidebar glyph |
order | 10…50 | 1–99, display order only; error and pending tie at 10, resolved by classification priority |
sortBy | 'time' (pinned: 'manual') | time = recent activity first; manual = host pin order |
surfaces.tab / .sidebar / .topbar | true / true / false | read-only chips in settings |
glyph.source | 'emoji' | emoji / upload / popout (reuse that category's popout art) |
glyph.value | per category | emoji, at most 8 code points |
glyph.uploads | [] | up to 6, ≤16 KB each, png/jpeg/webp/gif (no SVG) |
glyph.motion | pinned/error/pending/completed/running/idle → none / bounce / blink / none / spin / none | none / spin / pulse / bounce / blink |
glyph.motionTarget | 'row' | glyph = icon only, row = icon + title |
glyph.frequency | 1 | 0–1 step 0.05; 1 = every tick, 0 = never |
popout.enabled | true for error / pending / completed | pop an image when the session enters the category |
popout.builtin / .images | true / [] | built-in art, or random pick from the pool (≤6, ≤64 KB) |
popout.displayMs | 7000 | 2000–20000, 500 ms steps |
popout.entrance | 'slide' | read-only, fixed clip reveal |
popout.ambient / .exit / .side | breath / drop / 'auto' | idle motion / exit / edge |
sound.enabled | false | per-category sound switch |
sound.builtin | true | true = bundled wavs: pending/error → attention.wav, completed → complete.wav |
sound.uploads | [] | up to 6, ≤128 KB each |
If you are an AI agent
Read this part before touching anything. The user says one sentence; map it to a field, change it, confirm.
| The user says | What it actually is | Change | Suggested value |
|---|---|---|---|
| “It's too noisy” | Usually motion or popouts — sounds are off by default | sound: sound.enabled / sound.volume; motion: glyph.frequency; popout: popout.enabled | sound.volume: 20–30; glyph.frequency: 0.1–0.3; or turn the three popout.enabled off |
| “The blinking is annoying” | Motion, not sound | glyph.frequency down or 0; global switch display.reduceMotion = 'on' | frequency: 0 freezes that category |
| “Too cluttered” | Reduce quantity first, then surfaces | maxSeats; then positions.sidebar = false | maxSeats: 3–4 |
| “Only tell me when I have to act” | Keep error + pending only | turn surfaces off for completed/running/idle | {tab:false,sidebar:false,topbar:false} on those three |
| “Make it more obvious” | Add popout/sound/motion | popout.enabled, sound.enabled, frequency up | popout.displayMs: 12000, frequency: 1, overlay.sizeMin/sizeMax up |
| “I can't see category X” | Three switches in order | ① categories.<id>.enabled ② its surfaces ③ for idle, the recent window | ① must be true ② that surface's bit must be true ③ recentWindowMinutes > 0 |
| “My change did nothing” | Depends what changed | client.js → refresh page; index.js → restart GUI; a setting → check lastError(), it was probably clamped | — |
| “When does the top bar work?” | Not implemented | positions.topbar / surfaces.topbar | always false; do not promise it |
| “How do I pin / unpin?” | Not ours | the host's own sidebar row menu | we only read it; pinned.maxSeats limits how many show |
Two ways to write settings, same effect:
// A few fields: patch. NOTE glyph/popout/sound are replaced wholesale — get() first.
window.__HOTLIST_SETTINGS__.set({ maxSeats: 4 });
// Several places: get a copy, edit the whole object, write it back.
const next = JSON.parse(JSON.stringify(window.__HOTLIST_SETTINGS__.get()));
next.categories.completed.surfaces = { tab: false, sidebar: false, topbar: false };
next.categories.running.surfaces = { tab: false, sidebar: false, topbar: false };
window.__HOTLIST_SETTINGS__.set(next);
Rules for agents:
- Read before writing.
get(), then write; a patch belowcategories.<id>replacesglyph/popout/soundas whole blocks and silently resets the rest. - Never change
classify()order to “fix” visibility. Useenabled,surfaces,order. Priority is semantics. - Never reset user settings. No
reset(), no clearing the storage key, no “helpful” forced defaults insanitize*— see Modifying this plugin. - Pinning is the host's job. Do not invent a pin menu; read
pinnedSessionIds. - Top bar does not exist yet. Say so instead of faking it.
Sounds and the bundled TTS tool
First, the good news: nothing has to be installed to hear something. Two cue files ship with the plugin — attention.wav for error and pending, complete.wav for completed. Open the category in settings and flip sound.enabled. The rest of this section only matters if you want new lines or different voices.
1. No local text-to-speech? Use the helper in this repository
The GitHub repository contains tools/meru-tts/: a single-file Python script that turns one sentence into an mp3 or wav. It is not part of the running plugin — it only produces assets.
Limit up front:dsh plugin addcannot give you this tool — the npm package has notools/directory (filesonly listsindex.js,client.js,cordis.patch.yml,assets). “Install the plugin” and “build your own cues” are two separate paths; the tool requires cloning the repo.
2. Where the API key comes from (there is a free tier)
The tool defaults to Xiaomi MiMo TTS; register on Xiaomi's MiMo open platform and create a key (a free tier is enough for a handful of personal cues; treat the exact quota as subject to their current policy). Then export it:
$env:MIMO_API_KEY="your key"
The user has to register this themselves — it needs a phone number and identity verification, an agent cannot do it for them. Two other providers are supported: openai (needs OPENAI_API_KEY) and edge (edge-tts, needs pip install edge-tts, no key).
3. Generate one line
# --no-play is mandatory, otherwise the script plays straight to your speakers
python tools/meru-tts/meru-tts.py --provider mimo --voice 茉莉 --style "轻柔一点" --no-play -f mp3 -o done.mp3 "ding, done"
--list-voicesprints the voices of the current provider — do not guess voice names;--dry-playonly reports whether a local output device exists and produces no file.- Python 3.10+;
pylibs/(comtypes,psutil,pycaw) is bundled, no extrapip install. - The tool writes no config; the key is only read from the environment.
4. Load the result into the plugin
Three ways, pick one:
- Settings upload — Better Notice → the category card → sound group → upload. Easiest, recommended for humans.
- API —
window.__HOTLIST__.assets.add('<category>', 'sound', { name: 'done.mp3', dataUrl: 'data:audio/mpeg;base64,...' }). - Replace the bundled file — drop it in as
assets/sounds/attention.wav/complete.wav(same name, no code change); adding new files also means touchingBUILTIN_SOUNDandassets/sounds/manifest.json.
Check the size first: ≤128 KB per cue, ≤6 per category, ≤2 MB of all assets combined. Over budget is rejected (assets.add returns { ok: false, reason: 'budget' } or 'full').
5. One sentence to hand to any AI
Use tools/meru-tts/meru-tts.py from this repository to generate a notification cue saying “” with voice 茉莉 and a gentle style, output mp3, then tell me how to install it as Better Notice's error sound.
The agent should follow this order:
- Confirm
MIMO_API_KEYis set (echo $env:MIMO_API_KEY). If it is not, stop and ask the user to register — never invent a key. - Run
--list-voicesfirst to confirm the voice exists, then generate. - Check the file size: it must be ≤128 KB to enter the sound pool; if it is bigger, shorten the line or lower the bitrate and regenerate.
- Install it: have the user upload it in settings, or hand them a
window.__HOTLIST__.assets.add('<category>', 'sound', { name, dataUrl })snippet (<category>iserror/pending/completed). - Remind them:
sound.enabledisfalseby default for every category and must be switched on in settings.
Details in tools/meru-tts/README.md.
Assets
All three asset kinds can be uploaded in settings or loaded in bulk through the API: glyphs, popout art, sounds.
| Item | Limit |
|---|---|
| Items per category per pool | 6 |
| Glyph image (encoded) | ≤ 16 KB |
| Popout art (encoded) | ≤ 64 KB |
| Sound (encoded) | ≤ 128 KB |
| All uploads combined | ≤ 2 MB |
| Source file for glyph / popout | ≤ 4 MB |
Formats: glyphs and popout art accept image/png, image/jpeg, image/webp, image/gif (SVG is rejected); sounds accept MP3 / M4A / WAV / OGG / AAC / WebM. An oversized item, a full pool or a wrong type only rejects that one item; when the overall budget is exceeded, items are trimmed from the tail of the pools in category order.
Two different random rules — do not mix them up:
- Glyphs are seeded by identity: seed is a FNV-1a hash of
"<sessionId>|<category>"— the same session in the same category always shows the same image, across re-renders and reloads; only a category change switches it. - Popout art and sounds are random per event: a fresh pick on every pop and every cue.
Built-in art lives in assets/: three categories ship raster art (error, pending, completed) as <category>-<tier>.webp (tiers 64 / 320 / 780 / 1560); the other three (pinned, running, idle) have only a .svg, and every category finally falls back to an emoji. Sounds are assets/sounds/{attention,complete}.wav, served by the host half through the /hotlist-sound/ prefix route.
JavaScript API
Two objects are exposed for scripts and other agents.
// settings
const s = window.__HOTLIST_SETTINGS__;
s.version // 4 — settings schema version, not the plugin version
s.get() // current settings (the internal cached object; do not mutate in place)
s.set(patch) // shallow-merge and apply immediately
s.reset() // back to defaults
s.subscribe(fn) // subscribe, returns an unsubscribe function
s.defaults() // a fresh copy of the defaults
s.lastError() // last storage write error (string or null)
// settings + the three asset pools
const api = window.__HOTLIST__;
api.version // '4.6.1'
api.settings.get() / .set(patch) / .reset() / .subscribe(fn)
api.settings.backups() // → [{ at, version }]
api.settings.restore(index) // → { ok, at? }
api.assets.add(category, kind, { name, dataUrl }) // → { ok, reason?, bytes? }
api.assets.list(category, kind) // → [{ name, bytes }]
api.assets.clear(category, kind) // → boolean
api.assets.preview('pending') // play a cue now → boolean
kind:'glyph'|'popout'|'sound'.category:'pinned' | 'error' | 'pending' | 'completed' | 'running' | 'idle'.add'sreasonis one ofinvalid/full/refused/budget.
Two traps:
setmerges only two levels — root fields andcategories.<cat>are merged key by key, but anything below that (glyph,popout,sound) is replaced as a whole block.set({ categories: { error: { glyph: { frequency: 0.5 } } } })resets the rest oferror.glyphto defaults. Alwaysget(), edit the whole object, thenset().- Settings live in browser
localStorage, bound to the page origin. If the GUI port changes, that is a different origin and settings will look “lost”.
More examples (bulk import, loading assets from URLs, the three preview paths) in docs/EXTENDING.md.
Known limitations
Alt+1..9direct jump is not implemented. Bare Alt+digit is rejected by the host'sisWebBindingAllowed, and the third-partydsh-session-hotkeysgrabs the key in the capture phase. The field and UI exist; cycling is what you get today.- The host's own
refreshViewsthrows once withuiConversation.binding: unknown sessionafter a session is released (triggered when a seated session is entered and then you decline an approval / it ends). It is not in this plugin's call stack, and swallowing it with a globalwindow.onerrorwould hide real errors later, so it is left alone. - ⛔ only covers agent-level errors (
api-session/error, live-only, not replayed after reconnect). Tool failures do not count. Conversely, a session that ends because you declined an approval lands in ✅: classification only looks atrunning true → not running, regardless of why it stopped. - There is no separate “subagent” state. The host exposes no such signal, so “a subagent is running” is folded into
running. plan-reviewand ❓ are not distinguished. The host exposes a singlependingInteraction.kind.- Tab labels are strings only. The host renders them from a string, so tabs only ever show an emoji, never an uploaded image.
- The window top bar is not implemented.
positions.topbar/surfaces.topbaronly feed the visibility maths. - Tab and sidebar do not share one classifier.
classify()(tab) andsidebarStateOf()(sidebar) read the same data; since 4.6.0 their priorities agree (error > pending > completed > running > pinned > idle) but they are still different functions: the sidebar also honours the host'scompletionUnread, the tab surface only trusts this plugin's owncompletedAt, and sidebar errors carry their own 30 min TTL (SIDEBAR_ERROR_TTL_MS) instead of the 10 min the tab surface uses (ERROR_TTL_MS). So a session that finishes and immediately restarts can briefly show “tab ✅, sidebar ⏳”. This is a deliberate trade-off between the two surfaces, not a bug to fix. - The tab row can run out of room. The host folds extra tabs into an unreachable overflow menu, so this plugin computes its own limit from the row width.
- Motion is sampled, not always-on. Each category rolls its own frequency; on a hit, one seat of that category plays one animation. At most 4 animate at once (
MOTION_CONCURRENCY). - Sounds have a 1.5 s global throttle. A cue arriving less than 1500 ms after the previous one is dropped; the first round after a cold start only records. This is verified by source and unit test (group
sound player dedupe) — on a real machine it is hard to produce two genuine transitions that fast. - Settings are bound to the page origin.
localStoragebelongs to the origin, so a different GUI port means a different origin.
Modifying this plugin
For anyone — human or AI — who has to change behaviour. Goal: read this, find the place, verify it yourself, no follow-up questions.
About line numbers:client.js:NNNbelow are positions at the 4.6.1 release and are only a starting point. They drift as soon as the file changes, so search by symbol name (grep -n 'function classify' client.js). Mismatched line numbers are normal; the symbol name is the truth. Same fortest/classify.test.mjs: group names are stabler than line numbers.
Architecture in one paragraph
One npm package, two halves:
| File | Runs in | Does |
|---|---|---|
index.js | Host (Node) | One job only: registers the /hotlist-sound prefix route that serves assets/sounds/*.wav, because the host's shared asset route only accepts images (.wav / .mp3 would 404 there). |
client.js | Browser | All UI logic (~4,700 lines, single file, no build): tab seats, sidebar glyphs, popout, sounds, settings page. Registered via window.__ModuleLoader__.load({ id: '<package name>' }). |
cordis.patch.yml | Config | The bundle insert line that makes a profile aware of the package. |
client.js pulls in no dependencies except require('react'); DOM is written with h() (an alias of React.createElement). No bundling and no npm install are needed to change it.
Data flow
ctx.get('sessions').list.getSnapshot() ← session list (title, updatedAt, running)
ctx.get('uiSession').sessionStatus.getSnapshot() ← the single source of status truth
ctx.get('uiWorkspace').workspaces.list ← workspace grouping + pinnedSessionIds (host pins)
ctx.remote.$on('api-session/error', …) ← agent-level errors
│
▼
classify() category + token client.js:1477
sortSeats() ordering within a category client.js:1541
buildSeatPlan() apply limits, assign order client.js:1564
│
┌───────────┴────────────┬──────────────────┐
▼ ▼ ▼
mountTabSeats() createSidebarAdapter() HotlistSettingsPage()
tab seats (conversation.view) sidebar DOM settings page (settings.section)
client.js:4183 client.js:1861 client.js:2555
The three pure functions (classify / sortSeats / buildSeatPlan) never touch the DOM and can be fed fake data — they are the safest entry point for behaviour changes. Their results are also the only debugging handle besides window.__HOTLIST__: the export table at the end of client.js (client.js:4604 onwards) exposes classify, sortSeats, buildSeatPlan, readSettings, defaultSettings, sanitizeSettings, sidebarStateOf, HotlistSettingsPage and more, and the unit tests run the real source through it.
Touch points: change one thing, change these
1) Add a category
CATEGORY_IDS (client.js:122) → CATEGORY_ORDER (:123) → CATEGORY_GLYPHS (:141) → CATEGORY_MOTION (:142) → CATEGORY_SURFACES (:144, value is {tab, sidebar, topbar}) → defaults in defaultCategory() (:246) → accept the key in sanitizeSettings() (:764) → one rule in classify() (:1477) → one entry each in the dictionaries ZH (:307) / EN (:431) → one new group in test/classify.test.mjs. Missing CATEGORY_GLYPHS usually just means a blank icon; missing CATEGORY_SURFACES throws inside defaultCategory().
2) Add a setting
Five places, or you get “writes but does not read” / “Chinese leaking into the English UI”: the default (defaultSettings() :277 or defaultCategory() :246) → normalisation (sanitizeSettings() :764, always provide a fallback; invalid input falls back, never throws) → the control (HotlistSettingsPage :2555, row containers carry data-hotlist-row="<field path>", existing keys look like maxSeats, collect.completedTtlMs, categories.<cat>) → both dictionaries (ZH / EN, key sets must match exactly) → unit test. Do not bump SETTINGS_VERSION unless the data structure really changed (see below).
3) Add a motion
Add the name to MOTION_NAMES (:153) → a duration to MOTION_MS (:1736) → a @keyframes hl-<name> to SIDEBAR_STYLE (:1758) (animate transform / opacity only, never layout-triggering properties) → for the title variant, one entry each in TEXT_MOTION (:1745) and TEXT_BURST (:1752) plus @keyframes hl-text-<name>. Concurrency is capped by MOTION_CONCURRENCY (:192).
4) Add a popout-art slot
Art is assets/<category>-<tier>.webp (tiers 64 / 320 / 780 / 1560) for the three popping categories, a same-named .svg for all six, and an emoji as the last resort; resolution happens in popoutAssetRef() (:917). A new category that should pop also needs the popout.enabled decision in defaultCategory() (:247) and matching files in assets/, otherwise you only get the SVG / emoji fallback.
5) Replace a bundled sound
BUILTIN_SOUND (:212) is the only table mapping category → filename (SOUND_CATEGORIES (:214) is derived from it). Put the new wav in assets/sounds/, edit the table, keep assets/sounds/manifest.json in sync. The URL is built from SOUND_URL (:211) as /hotlist-sound/<file>; the actual serving is serveSound() in index.js — a GUI restart is mandatory (host routes register at start-up).
6) Change ordering or priority
- Display order:
CATEGORY_ORDER(:123) plus each category'sorder, consumed bysortSeats()(:1541). Placement only, never classification. - Classification priority: the order of the
ifs insideclassify()(:1477). A session belongs to one category; first match wins. - They are separate on purpose: users may change
orderbut cannot decide who wins a session. Editingclassifychanges semantics — always re-run thecategory orderandsidebar prioritytest groups.
Title motion, for agents
Sidebar titles can carry a CSS animation; tab seats cannot — the tab label is a string rendered by the host, so we can only put an icon and text there. “The text animates too” therefore only exists in the left sidebar.
- Field:
settings.categories.<id>.glyph.motionTarget - Values:
'glyph'= icon only;'row'= icon + title, started and stopped together. - Since 4.6.0 the default is
'row'(defaultGlyph(),client.js:233).
// Option A: change the default for every category (source edit, defaultGlyph() at client.js:233)
function defaultGlyph(category) {
return {
kind: 'emoji',
value: CATEGORY_GLYPHS[category] ?? '',
source: 'emoji',
motion: CATEGORY_MOTION[category] ?? 'none',
motionTarget: 'row', // ← this line; 'glyph' = icon only
frequency: MOTION_FREQUENCY.fallback,
uploads: [],
};
}
// Option B: per category (source edit, override inside defaultCategory() at client.js:246)
// e.g. only "pending" animates its title, everything else animates the icon only:
glyph: { ...defaultGlyph(category), motionTarget: category === 'pending' ? 'row' : 'glyph' },
// Option C: at runtime, no source edit (applies immediately; lost on reload unless persisted)
window.__HOTLIST_SETTINGS__.set({
categories: { pending: { glyph: { motionTarget: 'glyph' } } },
});
Option C has a trap you must know: set merges only two levels, and glyph / popout / sound are replaced as whole blocks — the line above resets pending.glyph's value, motion and frequency. Do get(), edit the whole object, set().
Adding a new title motion: one entry each in TEXT_MOTION (client.js:1745, looping) and TEXT_BURST (:1752, one-shot), a duration in MOTION_MS (:1736), and @keyframes hl-text-<name> in SIDEBAR_STYLE (:1758). Title keyframes only animate opacity and 1–2 px of translation, never scale (text goes blurry); concurrency is still capped by MOTION_CONCURRENCY (:192) and the rate is the per-category glyph.frequency (0–1, 0 = never).
Upgrades never overwrite user settings
Promise: upgrading the plugin never changes a setting the user already chose. Changing defaults affects new installs only.
How that is guaranteed:
- Defaults only fill gaps; anything on disk wins.
sanitizeSettings()starts from the defaults and then, field by field, keeps any valid stored value — so changingCATEGORY_ORDERordefaultGlyph()is invisible to existing users. - Adding a field: a fallback in
sanitize*is enough, do not bumpSETTINGS_VERSION. A version bump claims “a migration is required”, and a wrong migration silently wipes user data — no structural change, no version change. - Real structural changes (dropping a field, changing its meaning) must keep the chain “read old → convert → drop the old field” (today that lives in
sanitizeSettings(); the v3.1 → v4 migration went through it) and bumpSETTINGS_VERSIONby the smallest step. Themigrate v3 to v4test group is the acceptance test for that chain.
Three things you must never do:
- Never write “force it to the new default” inside
sanitize*— that rewrites user data on every read. - Never “tidy up” by deleting the localStorage key — category orders and uploaded asset pools live there.
- Never call
reset()duringapply()— one restart would zero the configuration.
Self-check: run node test/classify.test.mjs. The group current version keeps custom order (test/classify.test.mjs:605) is exactly this guard: it writes a v4 record with a custom order into a fake localStorage and asserts the custom value survives. Its siblings are user settings are never overwritten (:2427) and legacy default order is adopted, real choices are kept (:2461). If these go red, your change eats user data — do not commit.
Five hard rules
- Anything handed to the host must be a thunk, not a value. The host calls
command.label()directly inrefreshLabels(); passing a string keeps the wholeuiWorkspacefrom starting and the page shows a singleFailed to load plugins(that is how v4.3.0 broke). The settings section'slabel: () => t('settings.nav')is a thunk for the same reason (client.js:3879) — keep it one. - The package name must agree in three places:
package.jsonname= theidinwindow.__ModuleLoader__.load({ id })= the entry in the profile'sbundles. A mismatch reportsloaded without registering, which looks harmless on the page and is hard to trace. .wav/.mp3cannot go throughrequire.asset(): the host's package-asset route only accepts images; audio needs its own prefix route inindex.js.- Our static routes are unauthenticated:
/hotlist-sound/*and/plugins/**/assets/*do not check a token, so only public assets belong there — never surface user uploads through them. index.js/package.json/cordis.patch.ymlchanges need a GUI restart; aclient.jschange only needs a page refresh (but loaded modules are not hot-replaced, so refresh once before you start debugging to avoid reading stale code).
Self-check commands
node --check client.js # syntax. client.js lives inside a module loader, so a syntax error only explodes when the real page boots
node --check index.js
node test/classify.test.mjs # expect a final line: PASS — 62 groups
Plus a static gate before saving, available on the maintainer's machine only (not shipped with the repo):
It scansclient.jsstatically — it does not execute the code and does not spawn processes — and catches one specific class of error: a constant that is referenced but never declared.node --checkpasses those, and unit tests never boot the module, so neither catches it; on a real machine it surfaces as a singleReferenceErrorthat takes the whole render chain down. It also flags leftover references to removed fields.
Minimum regression bar: four green — node --check twice, PASS — N groups, and the static gate exiting 0. Then walk the real-machine checklist in docs/TESTING.md.
Development
cd <the directory you cloned into>
node --check client.js # client.js lives inside a module loader, so a syntax error only explodes when the real page boots
node --check index.js
node test/classify.test.mjs # expect a final line: PASS — 62 groups
The unit tests read the real client.js source, wrap it in a stub window.__ModuleLoader__ and run it: the fake browser is hand-written (window / document, no jsdom, React is a stub too), the code under test is the real source. They also include a few static scans: undeclared constants, CSS selector legality, and “shortcut labels must be thunks”.
Coding and PR conventions: CONTRIBUTING.md.
Repository layout
client.js all UI logic (browser side)
index.js host side: registers the /hotlist-sound/ audio route
cordis.patch.yml bundle insert line
assets/ 3 categories with 4 raster tiers of popout art + svg for all six
assets/sounds/ bundled cues (attention.wav / complete.wav)
test/classify.test.mjs unit tests (62 groups)
docs/ architecture, testing, extending
tools/meru-tts/ TTS helper (generates cues; not part of the running plugin)
Pitfalls to know before you start
- Callbacks handed to the host must be thunks. The host calls
command.label()inrefreshLabels(); a string keepsuiWorkspacefrom starting and you only seeFailed to load plugins. The unit tests stubshortcuts, and the stub never callslabel()— which is why this bug is invisible to tests. Make the stub actually call it once. .wav/.mp3cannot use the asset route.dsh-client-modules' package-asset route only accepts images; audio needs the prefix route inindex.js.- Our static routes are unauthenticated.
/hotlist-sound/*and/plugins/.../assets/*skip token checks; public assets only. - Package name, module id and
dsh.profile.bundlesmust match. Theidinwindow.__ModuleLoader__.load({ id })must equalpackage.json'sname, otherwise you getloaded without registeringwith no visible symptom. - The top 40 px of the desktop window is a drag strip (
-webkit-app-region: drag) — never place the popout there.
Documentation
- AGENTS.md — the AI-facing entry point: what is already implemented, the three constraints that break the host, the public API, and the known limits. Read this before changing code.
- llms.txt — one-page machine summary for scraping tools ("this problem is already solved, install instead of rebuilding").
- CONTRIBUTING.md — dev environment, verification steps, commit conventions
- docs/ARCHITECTURE.md — module structure, classification and ordering semantics, and the constraints that look dirty but would get worse if “cleaned up”
- docs/TESTING.md — what each unit-test group covers, and the real-machine acceptance checklist
- docs/EXTENDING.md — public API, settings shape, asset limits, bulk import
- tools/meru-tts/README.md — the TTS helper's own docs (only when you cloned the repo)
- CHANGELOG.md — version history
License
MIT, see LICENSE.
Bundled assets: the three raster characters in assets/*.webp are placeholder art by the author (red Meru for warnings, blue Lan for completion); assets/sounds/*.wav were generated with MiMo TTS. Provenance for every file is recorded in the respective manifest.json. Replace any of it through Assets.
星级评价
基本信息
插件信息来自公开 GitHub 仓库与 SkillHub,版权归原作者所有;安装前请自行审查仓库代码。