Clicking the local agent left connectionId null, so the roster treated
the registry primary (often an SSH box) as active and dropped its
profiles while inventing a "This device" shadow of default.
Inventory undialed SSH sources with a cached ls of ~/.hermes/profiles
instead of requiring the window to switch onto that machine. Hostile
HERMES_HOME values are rejected before the listing command runs.
Group chats could be created but never deleted — the only way out was
manually ungrouping every member, which still left the shared room log
in plugin storage forever.
The group-chat workspace header now has a trash button behind a
ConfirmDialog. Disband is soft: it clears every member's group
assignment (syncs cross-machine via ui_meta), drops the room log from
the atom and the persisted group-chats map, clears the needs-you badge,
and closes the room view. The members' per-group gateway sessions
("Group: <name>") are intentionally kept and remain reachable from each
bot's session browser. A room with a drive still in flight leaves a
runtime-only epoch-bumped tombstone so the round-robin loop bails at its
next member boundary; the tombstone is never persisted.
Exports createBudgetedLoop (+types) through the plugin SDK and migrates the
Bots plugin's face-animation clock onto it. The hand-rolled clock only
checked document.hidden; via the shared loop it now also pauses while the
window is minimized or unfocused, matching every other desktop render loop.
- sdk/index.ts: export createBudgetedLoop/BudgetedLoop/BudgetedLoopOptions
from @/lib/budgeted-loop with plugin-facing guidance
- hermes-bots/plugin.js: startFaceClock delegates scheduling to the SDK loop
when present (fps 15, idleWhen = no visible faces); paint body, IO-based
visibility tracking, and the 1Hz rescan are shared; the hand-rolled rAF
path remains as a feature-detected fallback for older desktops, per the
plugin's established SkillsView/McpTab pattern
- tests: new SDK-path case (fps/idleWhen wiring, visibility wake, re-entry
wake, dispose-on-stop) driven through an injected fake loop; verified to
fail with the SDK branch removed; existing fallback-path tests unchanged
Follow-up to #88523/#88542 from a community report (main agent listed twice,
both rows unnamed @default handles). Two distinct bugs, both specific to
remote-gateway-primary desktops:
1. Phantom "This device" default (electron): the roster enumeration dialed
ensureRegistryBackend for the registry's local entry unconditionally. On a
remote-primary desktop that forces resolveRegistryLocalRoute into the
forced-local branch — SPAWNING a local backend the user never asked for.
That backend enumerates a `default` profile, so a second default agent
appears AND the duplicate-handle rule forces -device suffixes onto the
real one. New shouldDeferLocalEnumeration() treats the forced-local route
as connect-on-demand (same courtesy as undialed SSH sources): the local
entry only enumerates when it is the delegate route (local-primary
desktops, byte-identical behavior) or a forced-local child is already
pooled (the user opened one).
2. Main agent renamed to a connection label (plugin): displayName keyed the
"show the connection label for a default row" rule off sourceScoped —
which annotation also sets on ACTIVE-source rows. A remote-gateway user's
main agent rendered as an IP-derived label (or bare handle) instead of
"Hermes"/their title. Key it off remoteSource: only THIN rows from
another source trade the friendly name for their source label.
Tests: shouldDeferLocalEnumeration route matrix (delegate always enumerates;
forced-local defers until a conn:local:: child exists; bare-key remote
descriptor doesn't count), displayName regression (active default stays
Hermes/title; thin remote default still shows its source label).
Follow-up to the cherry-picked fix for #88391: the bare `streamdown` import
broke the plugin's side-load contract (vm-harness tests and the legacy-sdk
tmpdir loader can't resolve bare specifiers, and the runtime plugin door only
maps @hermes/plugin-sdk and react). Export Streamdown from the SDK instead,
feature-detect it in plugin.js like SkillsView/McpTab (plain-text fallback on
older desktops), and fix the indentation at the render site.
Follow-up to the salvaged #88219 visibility/15fps work:
- Dormancy: the rAF loop stops scheduling frames when no faces are mounted
or none are visible, instead of running the 1Hz whole-document shadow-root
scan forever. A mounting BotFace or a face scrolling into view wakes it.
- Teardown: register() now hooks ctx.onDispose so disabling the Bots plugin
(or a hot reload) cancels the animation frame, disconnects the
IntersectionObserver, drops cached nodes, and clears window.__hbFaceClock.
Previously the loop ran until app restart even with the plugin disabled.
- Behavioral tests for park/wake/stop via a vm-extracted clock harness,
verified to fail against the pre-fix source.
Layers on the salvaged #88489 (@29206394) and #88341 (@frizikk):
- sdk: host.activeConnectionId() — registry id of the LIVE active gateway.
The salvaged fix classifies against the registry primary; after the user
activates a non-primary source's agent, profiles.list answers from THAT
source and primary-based matching would duplicate the active source's
agents again. Live id wins, primaryConnectionId is the fallback, the
legacy kind==='local' rule covers older desktops.
- plugin: roster/chip/picker list keys are botRowKey(bot) — source-qualified
(connectionId, name) — so same-named agents on two genuine sources can
never collide as duplicate React keys (the render half of the dupe-bots
smear: name-keyed rows + duplicate names = repeated blocks every poll).
Annotated active-source rows keep the plain-name key, so nothing remounts
when a desktop gains the union roster.
- tests: live-id-beats-primary regression, botRowKey stability, source-shape
anchor refresh.
The union agent roster (host.agents) enumerates EVERY registered connection,
including the active gateway that already answered profiles.list. The plugin
merger treated the active gateway's own agents as rows from other sources
because a remote-primary desktop reports them with connectionKind 'remote',
so every bot appeared twice (baseline) and kept growing with each refetch.
Match union agents to the active gateway via the new primaryConnectionId
field on the roster RPC response and annotate the local rows in place
instead of appending phantom copies. Same-named profiles on genuinely
separate sources (This device, other remotes) still get their own tagged
rows, preserving the @name-device disambiguation rule.
Fall back to the legacy connectionKind==='local' rule when
primaryConnectionId is absent (older Electron builds), so single-source
behavior is byte-identical.
Fixes#88344
The @ popover only completed filesystem references; bot handles worked
when fully typed (mention middleware parses at submit) but were never
offered, so users had to know the exact handle — worse with multi-source
@name-device handles. Fixes#88060 (ported from Hermes-Bot-Mode#43).
- composer contrib: new 'composer.atCompletions' data area
(ComposerAtCompletionSource) — contributed rows merge AHEAD of path
results; a throwing source drops its rows, never the popover
- use-at-completions: merge contributed entries in all three fetch paths
(gateway results, gateway-less, fetch error)
- SDK: export the new area + types for plugins
- bundled Bot Mode plugin: registers 'mention-completions' — roster
handles from the query cache (\u22645s stale), active profile excluded,
'default' offered as @hermes, multi-source @name-device handles via
botHandle, display name + connection label in the row meta, capped at 8
- registered early in register(ctx) so vm harnesses reach it before the
pane/UI registrations that stubs can't fully model
The Bots pane header + is now a dropdown (New Agent / New Group Chat).
New Group Chat opens a checkbox-picker modal: searchable roster list,
member cap at GROUP_CHAT_MAX_MEMBERS, group-name input that defaults to
the selected members' names, and a Create button that assigns the
existing per-bot group meta field - so the room rides the ui_meta sync
path unchanged and the user lands directly in the new room.
The teammate-messaging protocol told bots to send with
`chat -c "Bot Chat" -Q -q` and, on 'No session found', fall back to a
manual two-step (send without -c, then sessions rename) — a dance the
CLI already made unnecessary when --create-if-missing landed (#86794).
Profiles that never went through the Bots-panel birth flow (CLI-created,
pre-Bot-Mode, remote-source) hit that miss on every first contact, and
background sends swallowed the error entirely (the original silent-drop
in Hermes-Bot-Mode#48 / #88059).
- tools/bot_mode_probe.py: protocol command gains --create-if-missing
- bundled plugin: Bot Chat prompt section + @mention handoff note use
the flag; rename-dance instructions deleted
The capability epoch hashes the protocol section, so existing eternal
Bot Chat sessions pick the new instructions up on their next message via
the established once-per-change rebuild — no per-turn cache drift.
Live-verified: fresh profile with zero sessions, protocol command
created 'Bot Chat' and delivered (PONG round-trip); second send resolved
the same session by title (no duplicate); missing-title send WITHOUT the
flag still errors loudly on stderr.
Wire the continuity flag through every cron-creation surface, not just the
model tool:
- dashboard (web/): checkbox in the cron job editor; form state round-trips
the stored reserved 'self' entry into the toggle and strips it from the
context_from textarea; web_server dashboard validator skips 'self'
(create precedes the job's existence)
- Bot Mode Routines tab (hermes-bots plugin): Continuity checkbox in the
New Cronjob dialog, forwarded through cron.manage
- tui_gateway cron.manage RPC: optional continuity param on action=add
vitest cron-job suite 10/10 (4 new), tsc app project clean, py_compile clean.
The white catchlight dots in BotFace were static circles pinned at the
circle-face eye line (cy 16.5), while the animation clock moves the
pupils to the shape-aware eye line (cy 22 for the cloud). On the cloud
avatar the highlights floated above the eyes instead of inside them.
- Tag the catchlights (data-hb-hl-l/r) and move them with the pupils in
paintMathFace, offset upper-left of each pupil center.
- Render the initial eyes/catchlights/shut-lids at the shape-aware eye
line so the first frame matches the animated frames.
Pulls the multi-source roster into the bundled plugin: profiles.list rows
from the active gateway are merged with the host.agents() union roster
(hermes-agent #86875), so the Bots panel shows agents from every registered
Desktop connection with @name-device handles for duplicates. Feature-detected
and best-effort — an older Desktop build or roster failure leaves the
single-source list untouched.
Adapted for the bundle:
- useRoster queryFn combines the bot_mode_protocol capability read (which
landed after #68 was cut) with the multi-source merge
- multi-source-roster tests updated for the namespace SDK import harness
- soul-protocol-backfill anchor widened for the new botHandle(name, bot)
signature
Plugin suite: 143/143.
Found in live desktop E2E: the generated-identity path of composeSoul
still appended the protocol section even when the backend injects it
into the system prompt. New agents now get a clean identity-only SOUL
against capable backends; older gateways keep the append. Covered in
the capability-suppression test.
apps/desktop/src/**/*.js is gitignored (stale tsc output shadows .tsx),
which silently dropped the hermes-bots plugin.js from the adoption
commit — tests shipped, source didn't, CI ENOENT'd. Negate the pattern
for src/plugins/*/plugin.js: adopted plain-ESM plugins have no .tsx
sibling, so the shadow hazard cannot apply.
Adopts the Hermes-Bot-Mode desktop plugin (NousResearch/Hermes-Bot-Mode)
into apps/desktop/src/plugins/hermes-bots/, registered by the bundled
vite glob and ON by default. It stays a pure @hermes/plugin-sdk consumer
in plain-ESM plugin.js form; users disable it live in Settings > Plugins.
- contrib/plugins.ts: bundled glob accepts plugin.js entries
- contrib/runtime-loader.ts: a disk/runtime copy of an id that ships
bundled is skipped (standalone installs predating adoption cannot
double-register)
- package.json: check:test:plugins runs the plugin's node:test suite in
CI (138 tests)
- source: Hermes-Bot-Mode @ c19baba, incl. today's #107/#103/#99 merges
Plugins can now read host.state.busy and host.state.awaitingResponse
for the focused chat. These follow the same session slice the chat pane
uses, so a draft falls back to the global flags and a background turn
does not leak.
HermesPlugin/PluginRecord gain a description one-liner (kanban gets
one) shown in the inventory instead of the raw file path, and the
agent plugins section can open the backend's plugins dir — path from
config.get profile so it's profile-aware, local backends only since
openDir mkdir-creates.
It was a 1:1 rebuild of the core statusbar gateway item and shipped
enabled by default, so the pill showed up twice. Core chrome stays in
shell; demos that clone it belong in hermes-example-plugins.
The desktop board had no model control at all: a task ran whatever the profile
you assigned it to happened to be configured with, and the only way to point
one task elsewhere was `hermes kanban set-model` or the browser dashboard's
flat provider:model select.
Adds a Model row to New Task and to the task drawer, rendering the composer's
own picker via the SDK — same search, same provider groups, same submenu — so
the board and the chat bar cannot drift. Unset reads "Profile default" and
changes nothing; a pin reads "provider: model · High" with an inline clear.
Presets are read-only here: picking a model seeds the depth from what you last
used for it, but a per-task choice never rewrites what the composer opens at.
Fast mode is omitted rather than shown-and-ignored — it's a live-session
request parameter with no worker-spawn equivalent.
The New Task dialog opts out of DialogContent's clip: the dialog publishes
itself as the portal container for popovers opened inside it, so its
overflow-y-auto cropped the menu at the dialog's edge. The general fix is in
flight as #75600; this override is scoped to one dialog to avoid conflicting
with it and disappears when that lands.
The kanban drawer had grown the only edge-aware masked scroller in the
tree, and the next surface that wants one would have copied it. Lift it
to components/ui as FadeScroll, export it on the plugin SDK, and leave
kanban's ScrollFade as a name its call sites already pass `max` to.
The mask math comes out as two pure functions. jsdom's CSS parser drops
any gradient containing calc(), so a rendered mask-image can't be read
back off the style attribute -- edgeMask/scrollEdges are testable for
real where the component's inline style is not.
main landed a lint rule banning refs mirrored from reactive values in an
effect — they lag a render and cause stale reads. The lane-collapse
override tracker did exactly that with a counts ref.
Hold the empty/non-empty signature in state instead. React bails out
when it's unchanged, so a poll where no lane's emptiness moved costs no
extra render, and the comparison always sees the current value.
Creating a task was mouse-only: the header button, the empty state, or a
per-column hover +. Kanban now ships a real command, wired the way any
plugin should wire one.
One action id (kanban.newTask) registered into two areas: KEYBINDS_AREA
gives it dispatch plus a rebindable row in the shortcuts panel, and a
palette row whose `action` field points back at the same id so the live
combo renders as its hotkey hint. The handler is route-independent — it
parks the lane in $newTaskLane and navigates, so the page picks the
request up whether it was already mounted or is mounting for the first
time, then clears it so a remount can't reopen a dismissed dialog.
Default is mod+alt+n (⌘⌥N). mod+n and mod+shift+n are core built-ins a
plugin can't shadow; core uses alt only for the mod+alt+1…9 profile
slots, never with a letter, which leaves ⌘⌥<letter> free as the natural
namespace for plugin commands.
Label ships in the plugin's own locale bundles (en/ja/zh/zh-hant) via
ctx.i18n, so it localizes without a core en.ts edit.
Now that #67303 shipped the plugin-scoped i18n door, the kanban plugin ships
its OWN locale bundles via ctx.i18n.register instead of a core t.kanban
namespace — nothing added to core en.ts/ja/zh/zh-hant/types.ts. useKanban()
binds usePluginI18n('kanban') to the message SHAPE (one tiny generic) so
components keep their typed k.newTask / k.moveTo(label) access unchanged.
The plugin context only tracked contribution/socket disposers, so a plugin's
other side effects (store subscriptions) leaked across disable/re-enable. Add
ctx.onDispose(fn) — an arbitrary cleanup collected alongside the rest and run
on deactivate. bindApi now returns a disposer (unsubscribes its persisted-atom
listeners, closes the socket, drops the rest handle) and the kanban plugin
registers it via ctx.onDispose, so a toggle leaves nothing behind and never
duplicates listeners. Also DRYs the atom-persistence into one `persist` helper.
Every user-facing string in the kanban plugin now routes through useI18n
(new t.kanban namespace) instead of hardcoded English — en, ja, zh, zh-hant
in lockstep (typecheck enforces parity). Column labels/help move out of the
COLUMN_META const (visual-only now) into i18n via columnLabel/columnHelp;
LOCKED_COLUMNS/ARC_TITLES/complexity copy likewise. Matches the rest of the
desktop app, which is fully localized.
A running worker now polls its comment thread and folds new operator notes
into the live turn via the OUT-OF-BAND steer channel (list_comments_after +
a heartbeat-driven bridge, watermarked so history isn't re-injected and the
worker's own notes are skipped). No block→comment→unblock dance. Desktop's
composer sends notes live ("delivered within a few seconds") with "Requeue
with note" as the restart option and a help tooltip.
An "Estimate" action asks the auto-routed auxiliary model for a rough token
count + complexity band (S/M/L) with a one-line rationale — tokens, not
dollars, since providers don't report cost reliably. POST /estimate (typed
title/body, for the create dialog) and POST /tasks/{id}/estimate (existing
cards) share one core. Desktop renders it inline ("~15k tok · Medium") with a
"makes a model call" disclaimer; SDK exports compactNumber.
Boards gain an optional project_id. When set, the board's default_workdir
mirrors the project's primary repo and every new task inherits the project —
a deterministic worktree + branch per task — unless it names its own. New
GET /projects; board create/patch/list carry project_id + resolved name; the
create dialog defaults its workspace to the board's and allows a per-task
path override. Desktop: "Board settings…" gains a project picker.
The create form was cramped at max-w-md with a 60vh scroll cap. Widen to a
responsive w-[min(42rem,94vw)] and raise the scroll cap so the fields breathe.
The founding opt-in plugin (defaultEnabled: false): /kanban board + drawer,
live task_events via ctx.socket, ⌘-click bulk ops, auto-nudge dispatch,
collapsible lanes, board switcher, and prose activity — all pure SDK-consumer
work against plugins/kanban/dashboard/plugin_api.py. Backend: /boards totals
count live cards only.