better-noticev4.6.1

merusu/better-notice

客户端 MIT 最近更新 2026-11-11
0Stars
0Forks
0待解决 Issue
安装命令 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.

标签

deepseek-harness desktop dsh dsh-plugin notifications session-manager sidebar sound tab-bar web-ui sessions tab

概述

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.section entry, id hotlist, order 30)
Who you areRead
I just want it workingInstall → Verify → What it does. Defaults are fine; ignore every setting.
I want to tune itCustomization index — organized by what you want, not by field name.
I am an AI agentStart 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 soundsSounds and the bundled TTS tool.

What it does

Three surfaces, one shared decision:

  1. 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).
  1. Left sidebar — one status glyph in front of every session title (emoji by default, or an uploaded square image), optionally animated.
  1. 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 two settings-*.png screenshots 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 all and 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:

StateCharacterPoseWhy this colour
errorred Merumeru2-0-opinion — leaning out sidewaysred reads as warning: something needs you
pendingred Merumeru2-0-id — holding up an IDred again: you have to act
completedblue Lanlan2-0-sleepyblue 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

CategoryWhen it appearsTab seatSidebar glyphPopout imageSound
📌 pinnedYou pinned it in the host's own UI (hover/right-click a sidebar row → “Pin session”)✅✅——
⛔ errorThe agent reported an error (api-session/error), expires after 10 min✅✅✅🔈
🔐 pendingWaiting for your approval / answer / plan review (secondary glyph ❓)✅✅✅🔈
✅ completedThis plugin saw it finish, within the last 7 min✅✅✅🔈
⏳ runningWorking (includes “a subagent is running, parent idle”)✅✅——
💤 idleIdle, 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 plugin forwards 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.js changes apply on page refresh; index.js / cordis.patch.yml / package.json changes need a GUI restart.
  • Install from a git checkout when you also want the TTS helper (see Sounds). The npm package's files field only ships index.js, client.js, cordis.patch.yml and assets, so tools/ 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.yml inserts - insert: [{id: hotlist, name: '@merusu/dsh-better-notice'}] into the profile config.
  • Host half index.js registers the /hotlist-sound prefix route that serves the bundled .wav files. The host's shared asset route only accepts images, so audio would 404 there.
  • Client half client.js is declared through dsh.client (platform web, 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:

  1. window.__HOTLIST_VERSION__ → the version string, e.g. '4.6.1'.
  2. Object.keys(window.__HOTLIST__) → ['version','settings','assets'].
  3. Settings → the left nav has “Better Notice”, and the page shows a global block plus 6 collapsible category cards.
  4. Session rows in the sidebar now start with a status glyph.
  5. No Failed to load plugins banner 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.

KeysAction
Alt+Shift+[Previous seat (cycles within the current seat ring, wraps)
Alt+Shift+]Next seat
Alt+Shift+SPop 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…FieldValuesDefault
Only in the conversation tab rowpositions.tabtrue / falsetrue
Also show a status glyph in the sidebarpositions.sidebar (API only)true / falsetrue
Put it in the window top barpositions.topbarfalse (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…FieldValuesDefault
Limit tab seatsmaxSeats1–12, or 'auto' (fit the real width)6
Limit how many pinned sessions get seatspinned.maxSeats0–12 (0 = hide pinned entirely)3

Time — how long things stay hot

I want…FieldValuesDefault
Keep a just-finished session in ✅ longercollect.completedTtlMs1–1440 minutes7
Decide how stale a session may be and still count as idlecollect.recentWindowMinutesminutes; settings exposes 1–36, the API accepts 0–120 (0 = never show idle)5
The recent window has two layers on purpose: storage clamps 0–120 (so scripts can set 90), the settings control only offers 1–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…FieldValuesDefault
Turn a whole category offcategories.<id>.enabledtrue / falseall six true
Decide where that category sitscategories.<id>.order1–99error 10 / pending 10 / pinned 15 / completed 20 / running 30 / idle 50
Sort within the categorycategories.<id>.sortBy'time' (recent activity first) / 'manual' (host pin order)'time' (pinned = 'manual')
Restrict it to some surfacescategories.<id>.surfaces.{tab,sidebar,topbar}three booleansfirst two true, topbar false
Choose where the icon comes fromcategories.<id>.glyph.source'emoji' / 'upload' / 'popout''emoji'
Change the glyph charactercategories.<id>.glyph.valueemoji, at most 8 code pointsper category, see the table above
Use your own square imagecategories.<id>.glyph.uploadsup to 6, ≤16 KB each, png/jpeg/webp/gif[]
Change the motioncategories.<id>.glyph.motionnone / spin / pulse / bounce / blinksee reference
Animate the title toocategories.<id>.glyph.motionTarget'glyph' (icon only) / 'row' (icon + title)'row' since 4.6.0
Make it animate less oftencategories.<id>.glyph.frequency0–1, step 0.05 (0 = never)1
Pop an image when this category appearscategories.<id>.popout.enabledtrue / falseerror / pending / completed = true
Built-in artwork or your owncategories.<id>.popout.builtin / .imagestrue / []; ≤6 images, ≤64 KB eachtrue / []
How long the popout stayscategories.<id>.popout.displayMs2000–20000, rounded to 5007000
Ambient motion while it waitscategories.<id>.popout.ambientnone / breath / floatbreath on the popping three
How it leavescategories.<id>.popout.exitdrop / particles / fade / slide-outdrop
Which edge it comes fromcategories.<id>.popout.side'auto' (random each time) / 'left' / 'right' (mirrored)'auto'
Play a sound for this categorycategories.<id>.sound.enabledtrue / falsefalse
Built-in cue or your owncategories.<id>.sound.builtin / .uploadstrue / []; ≤6, ≤128 KB eachtrue / []

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 in sessionStorage (dsh.hotlist.open), never in settings.
  • Expanded area (compact multi-column): ① glyph (source / upload / value / frequency slider); ② motion (motion + target glyph/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

SettingDefaultRange / notes
maxSeats61–12, or 'auto' (follows the real tab-row width)
positions.tabtruemaster switch for tab seats
positions.sidebartruemaster switch for the sidebar (no settings row; API only)
positions.topbarfalsewindow top bar, not implemented, greyed out
takeOverStatusDottruetake over the status dot drawn by other plugins (dsh-sessions-manager)
hideNativeDottruehide the host's native status slot (no settings row since 4.4; field kept)
collect.completedTtlMs420000 (7 min)1–1440 minutes
collect.recentWindowMinutes5minutes. Settings offers 1–36; API accepts 0–120 (0 = hide idle)
sound.volume600–100, global
overlay.side'auto'auto / left / right
overlay.sizeMin / sizeMax260 / 520120–900 px (no settings row since 4.5; midpoint, narrowed to the viewport)
pinned.maxSeats30–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 / overlayKeytrue / false / 'Alt+Shift+S'no settings rows since 4.4; keys keep working

Per-category fields

SettingDefaultNotes
enabledall six trueoff = no classification, no seat, no sidebar glyph
order10…501–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 / .topbartrue / true / falseread-only chips in settings
glyph.source'emoji'emoji / upload / popout (reuse that category's popout art)
glyph.valueper categoryemoji, at most 8 code points
glyph.uploads[]up to 6, ≤16 KB each, png/jpeg/webp/gif (no SVG)
glyph.motionpinned/error/pending/completed/running/idle → none / bounce / blink / none / spin / nonenone / spin / pulse / bounce / blink
glyph.motionTarget'row'glyph = icon only, row = icon + title
glyph.frequency10–1 step 0.05; 1 = every tick, 0 = never
popout.enabledtrue for error / pending / completedpop an image when the session enters the category
popout.builtin / .imagestrue / []built-in art, or random pick from the pool (≤6, ≤64 KB)
popout.displayMs70002000–20000, 500 ms steps
popout.entrance'slide'read-only, fixed clip reveal
popout.ambient / .exit / .sidebreath / drop / 'auto'idle motion / exit / edge
sound.enabledfalseper-category sound switch
sound.builtintruetrue = 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 saysWhat it actually isChangeSuggested value
“It's too noisy”Usually motion or popouts — sounds are off by defaultsound: sound.enabled / sound.volume; motion: glyph.frequency; popout: popout.enabledsound.volume: 20–30; glyph.frequency: 0.1–0.3; or turn the three popout.enabled off
“The blinking is annoying”Motion, not soundglyph.frequency down or 0; global switch display.reduceMotion = 'on'frequency: 0 freezes that category
“Too cluttered”Reduce quantity first, then surfacesmaxSeats; then positions.sidebar = falsemaxSeats: 3–4
“Only tell me when I have to act”Keep error + pending onlyturn surfaces off for completed/running/idle{tab:false,sidebar:false,topbar:false} on those three
“Make it more obvious”Add popout/sound/motionpopout.enabled, sound.enabled, frequency uppopout.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 changedclient.js → refresh page; index.js → restart GUI; a setting → check lastError(), it was probably clamped—
“When does the top bar work?”Not implementedpositions.topbar / surfaces.topbaralways false; do not promise it
“How do I pin / unpin?”Not oursthe host's own sidebar row menuwe 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:

  1. Read before writing. get(), then write; a patch below categories.<id> replaces glyph / popout / sound as whole blocks and silently resets the rest.
  2. Never change classify() order to “fix” visibility. Use enabled, surfaces, order. Priority is semantics.
  3. Never reset user settings. No reset(), no clearing the storage key, no “helpful” forced defaults in sanitize* — see Modifying this plugin.
  4. Pinning is the host's job. Do not invent a pin menu; read pinnedSessionIds.
  5. 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 add cannot give you this tool — the npm package has no tools/ directory (files only lists index.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-voices prints the voices of the current provider — do not guess voice names; --dry-play only reports whether a local output device exists and produces no file.
  • Python 3.10+; pylibs/ (comtypes, psutil, pycaw) is bundled, no extra pip 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:

  1. Settings upload — Better Notice → the category card → sound group → upload. Easiest, recommended for humans.
  2. API — window.__HOTLIST__.assets.add('<category>', 'sound', { name: 'done.mp3', dataUrl: 'data:audio/mpeg;base64,...' }).
  3. Replace the bundled file — drop it in as assets/sounds/attention.wav / complete.wav (same name, no code change); adding new files also means touching BUILTIN_SOUND and assets/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:

  1. Confirm MIMO_API_KEY is set (echo $env:MIMO_API_KEY). If it is not, stop and ask the user to register — never invent a key.
  2. Run --list-voices first to confirm the voice exists, then generate.
  3. 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.
  4. Install it: have the user upload it in settings, or hand them a window.__HOTLIST__.assets.add('<category>', 'sound', { name, dataUrl }) snippet (<category> is error / pending / completed).
  5. Remind them: sound.enabled is false by 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.

ItemLimit
Items per category per pool6
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's reason is one of invalid / full / refused / budget.

Two traps:

  1. set merges only two levels — root fields and categories.<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 of error.glyph to defaults. Always get(), edit the whole object, then set().
  2. 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

  1. 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 the key in the capture phase. The field and UI exist; cycling is what you get today.
  2. The host's own refreshViews throws once with uiConversation.binding: unknown session after 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 global window.onerror would hide real errors later, so it is left alone.
  3. ⛔ 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 at running true → not running, regardless of why it stopped.
  4. There is no separate “subagent” state. The host exposes no such signal, so “a subagent is running” is folded into running.
  5. plan-review and ❓ are not distinguished. The host exposes a single pendingInteraction.kind.
  6. Tab labels are strings only. The host renders them from a string, so tabs only ever show an emoji, never an uploaded image.
  7. The window top bar is not implemented. positions.topbar / surfaces.topbar only feed the visibility maths.
  8. Tab and sidebar do not share one classifier. classify() (tab) and sidebarStateOf() (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's completionUnread, the tab surface only trusts this plugin's own completedAt, 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.
  9. 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.
  10. 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).
  11. 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.
  12. Settings are bound to the page origin. localStorage belongs 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:NNN below 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 for test/classify.test.mjs: group names are stabler than line numbers.

Architecture in one paragraph

One npm package, two halves:

FileRuns inDoes
index.jsHost (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.jsBrowserAll 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.ymlConfigThe 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's order, consumed by sortSeats() (:1541). Placement only, never classification.
  • Classification priority: the order of the ifs inside classify() (:1477). A session belongs to one category; first match wins.
  • They are separate on purpose: users may change order but cannot decide who wins a session. Editing classify changes semantics — always re-run the category order and sidebar priority test 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 changing CATEGORY_ORDER or defaultGlyph() is invisible to existing users.
  • Adding a field: a fallback in sanitize* is enough, do not bump SETTINGS_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 bump SETTINGS_VERSION by the smallest step. The migrate v3 to v4 test group is the acceptance test for that chain.

Three things you must never do:

  1. Never write “force it to the new default” inside sanitize* — that rewrites user data on every read.
  2. Never “tidy up” by deleting the localStorage key — category orders and uploaded asset pools live there.
  3. Never call reset() during apply() — 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

  1. Anything handed to the host must be a thunk, not a value. The host calls command.label() directly in refreshLabels(); passing a string keeps the whole uiWorkspace from starting and the page shows a single Failed to load plugins (that is how v4.3.0 broke). The settings section's label: () => t('settings.nav') is a thunk for the same reason (client.js:3879) — keep it one.
  2. The package name must agree in three places: package.json name = the id in window.__ModuleLoader__.load({ id }) = the entry in the profile's bundles. A mismatch reports loaded without registering, which looks harmless on the page and is hard to trace.
  3. .wav / .mp3 cannot go through require.asset(): the host's package-asset route only accepts images; audio needs its own prefix route in index.js.
  4. 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.
  5. index.js / package.json / cordis.patch.yml changes need a GUI restart; a client.js change 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 scans client.js statically — 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 --check passes those, and unit tests never boot the module, so neither catches it; on a real machine it surfaces as a single ReferenceError that 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() in refreshLabels(); a string keeps uiWorkspace from starting and you only see Failed to load plugins. The unit tests stub shortcuts, and the stub never calls label() — which is why this bug is invisible to tests. Make the stub actually call it once.
  • .wav / .mp3 cannot use the asset route. dsh-client-modules' package-asset route only accepts images; audio needs the prefix route in index.js.
  • Our static routes are unauthenticated. /hotlist-sound/* and /plugins/.../assets/* skip token checks; public assets only.
  • Package name, module id and dsh.profile.bundles must match. The id in window.__ModuleLoader__.load({ id }) must equal package.json's name, otherwise you get loaded without registering with 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.

星级评价

0.0 按 GitHub Stars 换算的社区热度,非用户打分
Stars0
Forks0
待解决 Issue0
最近更新2026-11-11

基本信息

作者MeruSu
版本4.6.1
开源许可MIT
默认分支main
入库时间2026-11-11

插件信息来自公开 GitHub 仓库与 SkillHub,版权归原作者所有;安装前请自行审查仓库代码。