#101112 made round members take their turns concurrently. That changed what
a group chat IS: later speakers in a round no longer saw earlier speakers'
replies, so bots answered the user independently instead of building on
each other. Group rooms are serial round-robin by design — this restores the
pre-#101112 round engine (group-rounds.ts, group-chat.ts, group-chat-view.tsx,
their tests, and the docs) byte-for-byte.
What stays from #101112: the per-turn poll wakes on the member session's
terminal frame (message.complete / error via host.onEvent) instead of
sleeping a fixed 2s between session.resume reads; 5s timer kept as backstop.
That is a pure latency fix with no change to room semantics.
Live A/B (real tui_gateway over WS, 4 members, one serial round):
2s poll 32.5s -> push-woken 22.5s. The remaining time is model latency.
Refs #92760
Bot Mode group rooms were slow by construction: the round engine ran every
member's turn one after another, and each turn found out its bot had finished
by re-reading session.resume on a fixed 2s timer. A 4-bot room paid
4 x (model latency + up to 2s) per round, serially.
- group-rounds: members of a round now take their turns concurrently
(Promise.all). Rounds stay serial so bots still build on each other's
replies. Each member's delta is computed at its own turn start and its
watermark advances only to the pre-turn log length, so sibling replies
that land while it thinks are delivered next round exactly once; a
member's own replies are excluded from its delta by author (they are
already in its session). Message cap enforced per round; stop path
interrupts every member mid-turn (room.turn -> room.turns map).
- group-turns: the poll wakes on the member session's terminal frame
(message.complete / error via host.onEvent), then re-checks at 250ms
until session.running clears. The timer poll stays as a 5s backstop for
hosts without the event tap. Feature-detected; node test harness unaffected.
- group-chat-view: "X is thinking..." lists every member mid-turn.
- docs: bot-mode.md describes concurrent rounds + push-woken replies.
Live A/B (real tui_gateway over WS, 4 members, one round, same model):
serial+2s poll 35.0s -> concurrent+push 8.6s; every turn woke on the event.
Refs #92760
Audit of every last_status reader outside the scheduler (rg last_status across
web/, apps/desktop/, hermes_cli/, tui_gateway/, tools/, scripts/, website/):
- web dashboard CronPage: last_status was never rendered at all — a
delivery_failed job showed a green 'scheduled' badge and only a small red
'delivery: ...' line. New pure cronLastResult() helper maps the closed
literal set to tones (ok=success, delivery_failed/blocked_config=warning,
error/unknown=destructive) and the card now shows an amber
'delivery_failed' badge (title = last_delivery_error).
- Desktop hermes-bots routine inspector: 'Last result' printed the raw
literal; routineLastResult() spells out each one ('Ran, but delivery
failed', 'Blocked by configuration (not run)', ...), unknown passes through.
- /cron list (cli_commands_mixin): 'Last run: <ts> (delivery_failed)' now
appends the delivery reason, since last_error is None for those runs.
- hermes cron list/doctor and the cronjob tool already handled the literal
on this branch; no consumer compared == 'ok' for success apart from the
cronjob manual-run path, which the branch already fixed.
- developer-guide/cron-internals.md: table of last_status literals + which
detail field carries the reason.
Live repro (real 'hermes dashboard' on a temp HERMES_HOME with a
delivery_failed job, CronPage rendered against the live /api/cron/jobs):
before — badges [scheduled, default, telegram:123]; after — badges
[scheduled, delivery_failed (warning tone, title 'telegram: 502 Bad
Gateway'), default, telegram:123].
The roster click's fronted-tab shortcut trusted the persisted session-tile
bucket (Local Storage 'hermes.desktop.sessionTiles.v2') unconditionally: a
persisted 'Bot Chat' tile naming a session the canonical registry no longer
resolves to — a superseded row from the retired ui_meta pointer design, a
re-minted canonical chat, a stale finished (often hidden) session — was
fronted on every click and remembered as the zone's active pane, so the row's
click target stuck to that stale session forever while its preview/age
described the live one, and clearing Local Storage only healed until the next
click re-persisted the same tile.
Per the Desktop guide the backend is authoritative for session state and the
renderer copy is a cache that must reconcile. focusWorkspaceOwnerSessionTile
now takes an optional staleness probe: tiles the probe rejects are discarded
(same no-undo rationale as discardSessionTile — resurrecting one would just
front the stale session again) and never fronted. The roster click supplies
the probe: a canonical-titled tile whose stored id matches neither the
server-resolved canonical_session registry row nor its compression-lineage
tip is stale, so the click falls through to the authoritative name-registry
open. Side-chat tabs carry no registry identity and are never judged; older
gateways without canonical_session (and shells without the probe) keep the
previous behavior unchanged.
Render durable per-member hold state in Group Chat with canonical resume guidance and accessible, theme-safe status copy.\n\nVerified by independent pre-commit review.
The gateway's session-backed MCP OAuth flow (mcp.servers.oauth.start) binds
its browser-callback listener on the BACKEND machine's 127.0.0.1. When the
Desktop app connects to a remote backend (SSH/Tailscale), the user's browser
resolves that loopback to the user's machine, the redirect dies, and every
OAuth catalog server (ClickUp, Hospitable, ...) fails in-app with no working
path — the exact topology from the 'MCP Recurring erros' support thread.
Fix mirrors the Desktop's native gateway login (native-oauth-login.ts):
- gateway: mcp.servers.oauth.start accepts client_redirect_uri (loopback-only,
RFC 8252-style validation); when supplied no gateway listener is bound and
the OAuth redirect_uri pins to the client's listener.
- gateway: new mcp.servers.oauth.callback RPC relays the client-captured
code/state into the flow; state verification stays in
DashboardOAuthFlow.deliver_callback (constant-time compare, replay-safe).
- desktop: mcp-oauth-callback-ipc.ts hosts a one-shot 127.0.0.1 listener in
the main process (hermes:mcp-oauth:listen/wait/cancel via preload bridge).
- desktop: hermes-bots mcp-setup.tsx prefers the client listener for local
AND remote backends, falling back to the legacy gateway-listener flow on
older gateways (feature-detect via start rejection).
- docs: remote-host MCP OAuth section documents the automatic Desktop path.
Validation: 19 new gateway tests (validator allowlist, listener skip, relay
accept/reject/replay) — sabotage-verified; 5 new desktop tests against a real
ephemeral listener; E2E through the real session registry + flow bridge with
a stubbed provider probe; tsc electron+renderer builds clean.
Review follow-up on #93911: the previous constant was set to 1_320_000 ms,
which is exactly the backend's maximum work budget (120s turn-lock wait plus a
600s attempt and its policy-gated re-run) rather than something greater than
it. After those bounded waits the handler still classifies the failure, builds
and runs the retry, serializes the terminal result, unwinds the temp-file and
lock scopes, and returns through the event loop -- so a turn that consumes
nearly the whole budget could still lose the race to the client timer and
resurface #93911 at the upper boundary, with the backend holding a typed
reason while Desktop reported its generic timeout.
The deadline is now composed from the three mirrored backend values plus an
explicit settlement/transport margin, so the arithmetic is visible instead of
being a magic number, and bot-relay-deliver-budget.test.mjs reads
config_defaults.py and methods_bot_relay.py to fail when a mirror drifts or
the margin stops being positive. Nothing in the type system links a JS
constant to a Python default; that test is the seam.
Also adds an adversarial virtual-clock regression: a gateway that answers only
after the full ceiling plus settlement is rejected by a deadline set at the
ceiling and accepted by one with margin.
host.requestProfile() had no way to express a per-call timeout, so every
routed plugin RPC fell to the gateway pool's generic 30s deadline. The
bot_relay.deliver contract is much longer: the backend holds the turn lock
(bot_mode.turn_wait_seconds, default 120s) and then runs a 600s turn, doubled
when the retry policy grants one bounded re-run, so methods_bot_relay.py
documents ~1320s as the bound a client must tolerate. Long turns (Computer
Use, deep research) were therefore killed at 30s and reported back as
unclassified failures rather than the typed reason the backend had classified.
requestGatewayForAgent()/requestGatewayForProfile() already accept timeoutMs;
only the two SDK layers above them dropped it. Thread it through and pass the
documented bound at the bot_relay.deliver call site. The argument is omitted
entirely when unset, so every other caller stays on the pool default.
The rename and settings dialogs stay mounted while closed, so they render
with a null board on every pass. Their mutation callbacks read `board!.slug`,
and the React Compiler lifts a callback's property reads into its render-time
dependency check — so the read escaped the closure and dereferenced null
immediately on mount, taking the whole contribution down behind its error
boundary.
The non-null assertion never guarded anything; it erases at compile time.
Resolve the slug null-safely in the component body instead, which is also
the form the compiler can hoist safely.
Only the bare-lambda shape is affected: the inline `useMutation({ mutationFn })`
this replaced memoized on the whole `board` object and kept the read inside
the closure, so the regression arrived with the extraction into
`useBoardWrite`, not with the feature.
Per-item menus across the app say "Rename", "Delete", "Export",
"Archive" — the row already names what you are acting on. A handful of
places had drifted to verb+noun or Title Case, so the same action read
differently depending on where you found it.
Sessions and projects now say "Rename…" like profiles and the file tree
already did. The per-profile context menu says "Export…"; the noun stays
on the profiles-list button and the native file-dialog title, which
stand alone. Bots drop "Delete Group" and "Edit Profile" for "Delete"
and "Edit…". Title Case gives way to sentence case in the file menu,
review tree, and model menu.
Nouns are kept wherever they carry weight: dialog titles, icon-button
tooltips, "Remove worktree" (its menu also has a plain "Remove"), and
"Open Bot Chat", which names the canonical session titled exactly that.
The board switcher could create and configure boards but not move,
rename, or remove one. Rename technically existed, buried as a field
inside "Settings…", which is why it read as missing; it now has its own
entry and the settings dialog is left owning scope alone.
Delete archives rather than erases — the board's directory moves to
boards/_archived/ and the toast names the path — and never appears for
`default`, which the backend refuses to remove.
The three dialogs had grown three copies of the same shell, the same
"invalidate the list and close" mutation tail, and the same name field,
so those are shared now instead of parallel-implemented.
GROUP_CHAT_MAX_ROUNDS and its four siblings carry over at the values
plugin.js shipped, so no rebase inherits a behavior change on top of a
rewrite. Making them configurable is live contributor work — #92213 for
per-room limits, #96842 for config plus a token budget — and both want the
same single seam, so say so where the constants are instead of adding a
config hook this PR has no consumer for.
Creating a bot opened its chat with the workspace fields omitted, because
they were spread only when the caller passed a staleness probe — and the
create path is the one caller that has none. The composer reads that scope to
stand its branch rail down in a companion chat, so a just-created bot showed
the git rail until the next click reopened the same row scoped. Live-verified
on Linux against a real backend, and carried over from the old plugin.js
rather than introduced by the rewrite.
The probe answers whether to navigate. What the session IS never depended on
it: a freshly minted Bot Chat is a bot's chat no matter who asked for it. With
the gate gone all four openers in this file are the same call, so they become
one.
The intro a new bot is born with was the first line of its forever-chat and
shipped in English, so a non-English user met their bot in a foreign language
and the bot's reply followed the prompt's language. It now resolves through
the plugin bundle in all four locales. Attribution — the other half of #91827
— still needs the lazy or silent birth that issue proposes, since
prompt.submit IS the user-turn API; the intro itself stays, per AGENTS.md.
The rest is the class the review named rather than only the lines it cited:
every user-visible string the group room and the bot-scoped cron pane own now
lives in the bundle. Where core already ships the vocabulary in every locale —
Remove, weekday names, Daily/Hourly — the plugin reuses it instead of shipping
a second, worse translation. The frequency and weekday option lists stop being
module consts frozen at import, which pinned whichever locale loaded first.
Prompts addressed to a model, cron syntax, and the 'You' author marker stay
hardcoded on purpose, each for a stated reason, recorded in the bundle header.
createCanonicalChat sends follow_profile_config and ensureGroupChatSession
sends room_plumbing + follow_profile_config again, as main's plugin.js did
before the rebuild. Without them, bot sessions created by this branch fell
back to the server's legacy title heuristics (exact 'Bot Chat' title;
hidden + 'Group: ' prefix) — the exact dependence the explicit contracts
were introduced to replace. Contract-shape tests pin both params.
The English bundle on the parent left the other locales to fall through.
Ship them the same way kanban does, using core's nouns, so a language
switch no longer paints the Bots rail in English.
Deleting the Bots home deleted the dead end that "Select a Bot or group
first." was apologizing for: with nothing selected the center is now an
ordinary session and + works there. The refusal still stands for the cases
that remain — a group room, and a selected row too orphaned to route.
The migration, its commit marker, and the rollback around it are all still
live in data.ts, but the suite covering them went out with the vm/regex
harness and was never ported — leaving only the happy-path commit ordering
asserted incidentally by bot-delete.
Nine cases: the sole-local topology gate (and the two refusals — multi-source,
and a batch where any one profile has no local route), the marker rule that
makes v2 authoritative (committed, markerless, crash-window), and the three
failed-commit paths (roll back to the last committed generation, clear both
keys when there is none, survive a failed cleanup).
migratedLocalRoutes is module-private, so where the old suite asserted the
map's size this one asserts what the map is for: no route adopted, nothing
written. Mutation-checked — dropping the marker rule fails three of them.
The adopt branch runs a full extra round-trip past the point every sibling
open in createCanonicalChat is probed at — registry miss, mint, title
conflict, re-consult — so it is the likeliest of them to land after the user
has clicked another bot, and it was the only one that navigated unguarded.
Adopting the winner still settles identity, which is always correct to
return; only the workspace steal is gated.
Ports 7c91079 onto the split modules — it landed on main in plugin.js, which
this branch deletes.
Every roster click resolved the bot's canonical Bot Chat by name and opened
it as a tab. Nothing records a tab close (this plugin keeps no closed set;
core's tile bucket only forgets), so a Bot Chat the user had closed came back
beside every newer thread on every bot switch. A click is now "go to this
bot": when the bot's workspace already holds tabs, the one the user last had
active is fronted and no chat is resolved or opened. The forever-chat opens
only when the bot has nothing open, or on the explicit ask — a new "Open Bot
Chat" row-menu item.
The claim such a click records carries only the fronted tab, so the reclaim
listener skips it and cannot resurrect the closed chat. focusExistingBotTab
is feature-detected, so an older shell keeps opening the canonical chat.
Dropped from the port: the Bots home "Open chat" button, a surface this
branch removes. The .mjs test is replaced by a real one — its two
source-reading cases become a render of the menu item in bot-row.test.tsx
and, for the reclaim guard, the claim-shape invariant the guard reads.
Stubbing usePluginI18n there also means the row's localized labels render as
text in tests instead of empty.
groupChatSyncMemberKey keyed on a field the descriptors never carry, so
every member hashed to the empty string and the round engine saw one member
where there were several; it keys on botRosterKey now, which is durable
across machines. botMetaWriteAt grew an entry per write and never dropped
one — it prunes past 60s, which is longer than the echo it exists to
suppress. aliasRouteIndex replaced the whole map on rebuild, so a slower
rebuild finishing second clobbered a newer one; a generation token means
only the newest result lands. Regression tests for each.
Dead since the split: EYE_X/EYE_Y, generatedSessionTitle, enabledMcp,
groupChatSyncDeletedRevision — each down to a declaration with no reader.
The bot source-status labels were half-localized, so they moved onto one
helper here rather than in the i18n pass. no-redeclare is disabled inline
over the two overload pairs it misreads, rather than in the shared config.
A raw checkbox in the routine editor where the SDK already exports one. The
same resizable-panel style block inline in three dialogs, now a
ResizableFrame beside the other dialog parts. Four inline styles that were
Tailwind spelled longhand — model-picker's was literally flex flex-col
gap-2. The twenty that remain are computed grid columns and avatar sizes,
which have to stay inline.
Both roster filter menus, the avatar picker's tabs. Renamed group.newDesc to
group.manageDesc since it describes managing an existing group, not making
one.
The getPluginCtx()?.i18n.t() sites only guarded the context, not i18n on it
— a plugin context without the bundle threw mid-render and painted an empty
rail. Guarded both.
petFrameCache is keyed by spritesheet URL over a 4500-pet gallery and holds
decoded PNG data URLs, so scrolling pinned every pet you passed until the
window closed. Capped at 120 — five pages, so scrolling back stays instant
and a miss only re-pays the fetch and crop. relayAgentsCache already swept
stale ids, but the sweep sits behind an early return that a shrink to one
connection skips; capped at 32, safe because every live connection is
rewritten each cycle so eviction can only reach ids that stopped being
fetched.
relay.ts also carried eight loose module-level lets, the state outlier
across the plugin's modules. Nothing renders from them, so they stay module
scope — collapsed into one record rather than moved to a store.
Six verbatim copies of the leading-glyph box down to two owners: transcript
lines get SCAFFOLD_GLYPH_CLASS from scaffold-row, sidebar rows get
SIDEBAR_ROW_LEAD. The bot rail's GatewayKindGlyph was a fork of core's
ConnectionGlyph down to the icon set, so it wraps the real one now and the
duplicate kind-to-icon tables are gone.
The plugin shipped as bundled JavaScript, so eslint never saw it. Now that it
is .tsx under src/, the whole ruleset applies: sorted JSX props, curly braces,
statement padding, unused imports.
Three of the ref writes the atom-mirror rule flagged are the cases its own
comment carves out — a scroll-position tracker fed by a DOM listener, a
previous-value tracker for the hidden -> visible edge (lagging a render IS its
contract), and a timer handle cleared on unmount. Those get the documented
disable. The fourth was a genuine mirror: McpSetupButton copied its profile
prop into a ref every render so two callers could read it. They read the prop
directly now, and the ref holds only the profile the component creates on
demand for the New Bot flow.
no-redeclare counts a TypeScript overload signature as a redeclaration of its
implementation, which is what botSelectionKey and botMetaKey tripped. Swapped
for the TS-aware version alongside the no-undef swap already there; hermes-ink,
whose vendored yoga bindings merge a const and a type under one name, extends
its existing carve-out to the new rule name.
A bot's canonical chat is the relationship — /new inside one would fork it into
a scratch session, so the composer reroutes /new to /compact there. The guard
deciding "is this chat the canonical one" read host.activeSessionId, which is
not on the host: the real atoms are host.state.activeSessionId (runtime id) and
host.state.focusedStoredSessionId (stored id). The optional chain swallowed it,
the comparison ran against null every turn, and /new reset forever-chats for as
long as the guard shipped.
It reads the focused STORED id now, which is the id space canonical_session
reports in. The comparison itself moves into isCanonicalChatOnScreen so a test
can drive it — matching either the durable registry row or the
compression-lineage tip, since a compacted Bot Chat is on screen under its tip
id while the registry still names it by the root.
The old harness sliced source text out of plugin.js, re-evaluated the
fragments through vm.runInNewContext, and in a good number of files simply
regex-matched the source for a symbol name. Nothing it asserted survived the
split into modules, and its blind spot was load-bearing: the /new guard
regex-matched clean for as long as it shipped dead.
These are the same contracts driven against the real modules through the real
imports, colocated beside the code they cover. Files whose only content was a
source regex or a re-implementation of the function under test are dropped
rather than translated.
An untouched bot chat was blank: core's splash stands down for any
session that exists, and nothing took its place. Claim the new
`chat.empty` slot and title the chat with the bot's face above its name
in the splash's lettering, so an empty conversation still says whose it
is. Rendering "HERMES AGENT" there would have been the wrong identity
for the surface.
The transcript hands its slot the RUNTIME session id while a canonical
Bot Chat is keyed by its stored one — the two id spaces behind the
#93080 misroute — so identity resolves through the focus store the rest
of the plugin already trusts. Metadata is read with `botRosterMeta`
rather than by name: it is keyed by the route it came from, so a by-name
read misses the entry a bot's avatar and rename actually live under.
The name, not the stack, sits on the center line; the face hangs above
it by half its own block.
Bot Mode arrived as a 16,933-line plugin.js that reimplemented most of the
app: its own scroll container, color palette, status dots, empty states,
buttons, time formatting and cron surface, none of which could follow the
theme. Split it into 41 focused modules and route every one of those through
the primitives core already ships, so a Bot row now renders the same
SessionStatusDot, swatches and age labels as the session row beside it.
User-facing strings move into a plugin locale bundle instead of sitting
inline, and the UI settles on "bot" as the noun (model-facing prompt text
still says "agent"). The codemod scaffolding that drove the jsx() -> TSX
conversion retires with the conversion.
hermes-bots/plugin.js was 16,193 lines of hand-written JSX compiler output
— it imported { jsx, jsxs } from 'react/jsx-runtime' and called them
directly. Because the file was .js, eslint (scoped to plugins/**/*.{ts,tsx})
and tsc (allowJs: false) both skipped it entirely, so none of the design
system, import-fence, or type rules that govern the rest of the app ever
reached the largest UI surface we ship on by default.
Converting it back to JSX is an inverse-compile, not a rewrite, so it is
done by script rather than by hand:
- scripts/codemod/dejsx.mjs rewrites jsx()/jsxs() calls into JSX elements.
735 conversions, none skipped. Comments between children become
{/* … */} containers, since a bare // in children position is text.
- scripts/codemod/verify.mjs proves the result. It recompiles the .tsx
through esbuild — an implementation independent of the codemod — and
compares it to the original after normalizing away esbuild's own
rewrites (quote style, void 0, numeric format, string/template folding,
export hoisting) and alpha-renaming every binding per scope. Output:
IDENTICAL across 333,711 normalized characters.
Two rewrite classes are semantics-preserving but not byte-identical, so
they are named and counted rather than hidden: 12 spread-children folds
(JSX has no spread-children syntax; children={[...xs]} can only be written
{xs}, which React flattens identically and which is the idiom the whole
ecosystem writes) and 1 redundant key prop (passed both in props and as
the third argument; React's jsx runtime never copies key into props).
No behavior change. 16,193 lines become 16,050 of real TSX.
Clicking the Cronjobs tile shifts focus onto the tile itself, momentarily
dropping bot-chat workspace ownership — syncRoutinesPane then unregistered
the pane out from under the user's own click, with no way back. Keep the
tile while Bot Mode is on screen and the tile is the focused surface;
leaving Bot Mode still unregisters as designed. Live-verified.
The agent loop writes an internal "(empty)" sentinel when the
nudge/prefill/empties/fallback ladder all fail. The gateway converts it
into a user-friendly notice at delivery, but the desktop group-chat
bridge appended the raw sentinel into the room log (seen posting
"(empty)" in a Bot Mode group room), and it synced to the shared
ui_meta for mobile.
Normalize at the single choke point, appendGroupChatEntry, mirroring
gateway/run.py substitution so group chat and gateway surfaces show the
same text. (pass)/empty silence semantics unchanged. Includes a
regression test proven to fail on the pre-fix code.
Fixes#94308
Address review feedback on #94386: the new tests only exercised
runGroupChatMemberTurn's use of pickGroupTurnReply. Add the analogous
case for harvestStrandedGroupReply (substantive answer -> synthetic
continuation nudge -> (pass) tail) and document the pass-only tie-break
(newest wins) in pickGroupTurnReply's docstring.
runGroupChatMemberTurn (and harvestStrandedGroupReply) selected only the
last assistant message in a finished turn. A Codex intent-ack continuation
nudge can land a complete, substantive room answer and then get a
synthetic "(pass)" reply to the nudge itself — the terminal message picked
by the old scan, which silently discarded the real answer (#94376).
Both call sites now scan the messages appended this turn for the last
substantive (non-pass) assistant reply, falling back to a pass only when
no substantive answer exists in that window.
Source-contract tests (the group-room-ux pattern): the workspace renders
the Stop button only while room.running, wires it to stopGroupThread
(not the #94570 per-member interrupt spray), and carries no hardcoded
CJK label.
Salvaged from #94570 (@ShonnQ): the Activity bar gains a Stop button
while a round is running (room.running), plus an inline Stop on the
expanded 'working' activity row. Rewired from the original per-member
session.interrupt spray onto the stopGroupThread primitive so the round
loop actually stops (epoch bump + holds + on-turn interrupt) instead of
marching to the next member; labels are plain English like the rest of
the plugin's UI strings.
Co-authored-by: Hermes Agent <agent@nousresearch.com>
stopGroupThread(group, thread, members?) is the room's first true
cancellation primitive: it bumps the room epoch (the driving loop bails
at its next member boundary), sets #93129 holds for every member (no
future turns until an explicit release), records a 'stopped' activity
event on the new epoch, and sends session.interrupt to the member
currently on turn via its own route — previously the plugin issued zero
interrupt RPCs, so 'stop' meant waiting out the in-flight model call.
The runGroupChatMemberTurnLeased poll loop now abandons a turn whose
dispatch epoch went stale WHILE its member is held — the stop signature.
An ordinary newer-send epoch bump without a hold still polls to
completion so late work keeps landing (#93127 commit check unchanged).
Follow-up to the #94755 salvage: every runGroupChatRounds exit recorded
'settled', so a room that died at the round/message/continuation cap looked
identical to genuine consensus. Track the exit kind and record 'capped'
(with label + glyph) when a cap ended the drive, and pin the exit-path
wiring with source-contract tests.
- GROUP_CHAT_MAX_CONTINUATIONS=2 caps continuation rounds independently of
the message cap, so pathological @mention chains can't consume the room's
whole budget on handoffs.
- unaddressedGroupMentions now orders by log INDEX instead of entry id:
ids are UUIDs (groupChatEntryId), not monotonic — string comparison could
both re-drive answered members and miss stranded ones.
- New unaddressed-mentions.test.mjs exercises the REAL function via the
vm-slice pattern (replacing concept-only helpers) and pins the ordering
fix with a UUID-vs-log-order case.
In Bot Mode group chats, a member reply that @mentions a teammate never
drove the cited bot when the current round went quiet: the
'spokeThisRound === 0' early exit treated a zero-reply round as 'everyone
passed' and settled the room, even though the reply's @mention was
pending. The same silent settle happened whenever GROUP_CHAT_MAX_ROUNDS
or GROUP_CHAT_MAX_MESSAGES landed between the mention and the next
round (#94478).
Fix:
- unaddressedGroupMentions() detects member-to-member citations in the
thread whose cited member has not posted anything after the citing
entry (self-mentions excluded; user sends re-drive everyone anyway).
- The quiet-round exit now checks for such pending handoffs and runs one
bounded continuation round driving exactly those cited members — same
holds/stranded/epoch/cap machinery as ordinary rounds, so nothing new
is trusted.
- If the continuation also produces nothing (pass, failure, or cap), the
room settles as before; behavior only changes where a bot was actually
called and never answered.
Regression tests in plugins tests family (2 new); full hermes-bots
plugin suite stays green (558/558).
Fixes#94478
The Bots editor's model write (profiles.configure) was the one switch
surface that bypassed the data-policy / expensive-model selection guard:
a guarded pick (e.g. muse-spark contributor tier) was applied silently,
with no confirm flow anywhere — the #95293 remainder after the core
picker's confirm handshake landed in use-model-controls.
Gateway: profiles.configure now answers confirm_required +
confirm_message for a guarded model (same handshake as config.set
model) and writes NOTHING until the client resends with
confirm_expensive_model: true. Other sections still apply; the pending
model section is not reported as failed.
Desktop: the confirm flow is extracted out of use-model-controls into
one shared applier (lib/guarded-model-switch.ts, exported through the
plugin SDK) — warning toast, staleness-guarded Confirm, single
confirmed resend, never a retry loop. The core picker and the Bots
editor now consume the SAME handler; the Bots editor's Confirm resends
only the model section with confirm_expensive_model: true.
Fixes#95293 (Bots surface remainder).
The Bots model picker's catalog read rode the bot's own socket with no
deadline: a wedged dial left the query pending forever, so the picker
spun indefinitely. On top of that every fetch forced refresh:true,
bypassing the staleTime cache, so each Bots view remount (tab re-front,
dialog reopen, pane visibility flip) knocked the picker back into its
loading state and wiped the staged provider/model pick mid-edit.
Bound every attempt to 20s (rejection falls through to the picker's
existing free-text fallback), drop the forced refresh so the read
participates in the cache like every other surface's catalog, and pin
the contract with a red->green regression suite.