Phase 1 leaves on both pipes reaching EOF without an exit status, so the
final wait was the only thing holding the turn -- and it was a bare
child.wait(), which stranded the cancel channel against a child that
closes its pipes and lingers. Poll cancellation there too.
`run_script` and `run_streamed` both left their select loop on stdout EOF
and then ran unbounded post-loop drains, so `child.wait()` sat downstream
of a read that a surviving descendant can hold open forever. Pipe EOF is
not the child's to give: the write end is inherited by every descendant
spawned without its own redirection, and `hermes update` deliberately
runs its build steps with stdout inherited. One resident gateway stranded
the whole update, exit code included.
Both now go through one `pump_child`, which takes the exit status from
waiting on the process and bounds the drain from the moment it exits — a
slow child is not a stuck one, so nothing is metered while it runs. An
abandoned drain says so in the log rather than silently truncating.
Cancelling was not an escape hatch either: `start_kill` reaches the
child, not the grandchild with the handle, so the bounded drain is what
lets a cancel return at all.
Same bound `Invoke-HermesStep` grew in windows.ps1 (#90455), and the same
shape as Go's `exec.Cmd.WaitDelay`.
The loading placeholder had its own copy of the row grid — its own min-height,
its own two columns, `pl-2` and no trailing inset — so its right-hand block sat
several pixels off from where the real rows land. The list stepped sideways as
sessions resolved.
Compose the shared row chrome instead, which is where that inset lives.
"Load more" and a workspace's "show more" hang off the bottom of a list rather
than sitting in a row, so they never saw the shell's trailing inset and stayed
flush against the edge every row above them now stops short of.
The cron row had its own copy of the row grid — its own min-height, its own
`grid-cols-[minmax(0,1fr)_auto]`, its own `pl-2 pr-1`, and a comment explaining
that the numbers were chosen by hand to line up with the session rows above it.
They had already drifted apart on the right edge.
Compose SidebarRowShell / SidebarRowBody / SidebarRowLead / SidebarRowLabel
instead, so a cron job and a session share one definition of what a row is and
cannot drift again.
SidebarRowShell owned the row's height and, through the body, its leading
inset — but nothing owned the trailing one. The actions slot rendered with no
padding, so the age, chips and kebab sat on the row's border box. That is the
same pixel a working row paints its arc on (`.arc-row` sets `--arc-standoff:
0rem`), so the animation ran straight through the text.
Give the shell that inset. It is the only box containing both the one-line
row's actions column and the card variant's in-body cluster, so one class
covers every trailing thing a row can render. The card drops the body's
label-to-actions gap in exchange: it has no such column to clear, and keeping
the gap would pull its header in past every line below it.
Adopt upstream's one-hidden-forever-chat model and remove the per-bot hidden
session browser while preserving connection-bound remote Bot opening, the full
remote action menu, same-name isolation, and global Sessions navigation.
Disband removed the room log and each member's group membership, but
BotsPane's mergeServerMeta then overlaid the STALE cached roster snapshot
(fetched before the disband) whose ui_meta['hermes-bots'] still carried the
old groups array — spreading it back over local meta and re-listing the
group as an empty row. Any later meta write for the bot (pin, title,
canonical-chat pointer) re-uploaded the resurrected membership server-side,
making the ghost permanent. Same stale-overlay class could revert renames,
re-group left members, and undo pins/hides.
Fix (class-level, not per-field):
- saveBotMeta stamps a per-bot last-local-write time (re-stamped when the
profiles.configure write settles).
- useRoster stamps each snapshot with its fetch ISSUE time (fetchedAt).
- mergeServerMeta skips overlaying any bot whose local write post-dates the
snapshot; the next (fresh) fetch overlays normally, so server truth still
gets the last word. No-fetchedAt callers behave exactly as before.
- disbandGroupChat / renameGroupChat invalidate the roster query so all
surfaces converge on a fresh snapshot immediately.
Tests: new regression (stale snapshot cannot resurrect a disbanded
membership; fresh snapshot still wins) proven to fail without the fix via
sabotage run; fence-off compatibility test; hide-bots shape regex updated.
341/341 plugin tests pass.
The two deepEqual tests pin the exact env object, so the new spawn tag
field made them red. Assert the tag in both shapes and add direct
spawnTag() coverage (winms-derived seconds, dash fallback, non-winms
markers rejected).
Every long-lived Hermes process is now positively identifiable so reapers
never have to guess lineage from PPID archaeology or cmdline shape:
- hermes_cli/process_identity.py (new): HERMES_SPAWN tag build/parse,
spawn-ledger.json self-registration keyed on (pid, create_time) — PID
reuse cannot forge the pair — with #89298-style corrupt-file quarantine,
and a kill-on-close job-object self-attach (BREAKAWAY_OK preserved for
the existing CREATE_BREAKAWAY_FROM_JOB escape hatches).
- serve/dashboard (web_server.py) and the gateway entry point register
themselves at startup and attach to the job; Desktop legacy
HERMES_PARENT_PID/winms marker reused as spawner identity so lineage
works with every Desktop version.
- Desktop stamps HERMES_SPAWN on backend spawns (parent-process-identity.ts).
- hermes update gets a positive-identity rung ahead of the heuristic ones:
_ledger_reapable_backend_pids reaps holders the ledger PROVES are orphaned
backends (purpose reapable + recorded spawner provably dead) in ANY update
context. Ledger-unknown holders fall through to the existing rungs.
22 new tests, sabotage-verified.
Approvals (pending_approval) had the identical blind spot as clarify:
blocked server-side in the hidden member session, invisible until timeout.
- syncGroupClarify mirrors pending_approval alongside pending_clarify
(clarify outranks when both appear); entries carry kind, command, the
server's choice set (once/session/always/deny, fallback once/deny), and
the runtime session id approval.respond keys on.
- The room card renders approvals as command-in-code + choice buttons
(closed set, no free text; deny tinted destructive) and routes
approval.respond via the member's own source.
- 6 new tests incl. an end-to-end blocked-on-approval drive; sabotage
run (approval mirroring disabled) fails all 4 behavioral tests.
Group members run in hidden plumbing sessions, so a member's clarify tool
blocked server-side with no surface to answer it — the room showed
'@lead is thinking…' until the 300s clarify timeout (salihsungur's report).
- The turn poll and the stranded-harvest pass mirror each member's
`pending_clarify` resume field into $groupClarify and hold the turn
deadline open while a question waits (bounded by the existing hard cap).
- The room renders a question card per blocked member — choice buttons,
free-text, batch sub-questions — and answers route via clarify.respond
through the member's OWN source (requestForBot), so cross-connection
members work. The answered exchange echoes into the room log.
- needs-you badges the roster row while a question waits; disband/rename
clear mirrored cards; older backends without pending_clarify no-op.
- 7 new tests incl. an end-to-end blocked-turn drive, sabotage-verified
(disabling the poll gate fails the drive test).
parseBackendOwnership returned [] for unreadable JSON and reapOrphans
unconditionally rewrote survivors — one corrupt read replaced the roster
with [], permanently orphaning every backend it described. The sweep now
detects corruption, parks the file as .corrupt (evidence preserved), and
skips the rewrite; empty/missing files keep the legacy sweep behavior.
PR #90732 removed the per-bot Sessions browser (right-click -> Sessions),
but the Disband-group confirm dialog still told users they could open the
kept 'Group: X' sessions 'from each bot's session browser' - an affordance
that no longer exists. Drop the stale clause; also update the one test
comment that still described the removed workspace.
Review follow-up from #90732 (found by the 3-angle review pass).
The #90732 adoption scan used session.list's 200-row recency window. A busy
bot profile (group-chat traffic, routines, or accumulated fork spam) pushes
an older forever-chat past row 200, the scan misses it, and the mint path
re-enters the unique-title-conflict fork loop — same pathology, higher
trigger threshold.
Profile → Named Session is an exact registry (UNIQUE title index), so
consult it exactly:
- session.list gains a `title` param: indexed WHERE title = ? lookup,
window-free, hidden rows resolve, archived/deny-listed do not,
compression lineages resolve to the live tip (resolved_id), mirroring
profiles.list's preferred_session resolver.
- findExistingCanonicalChat sends title: 'Bot Chat'. Older gateways ignore
the unknown param and return the windowed listing — the local scan stays
as the compatibility rung.
- Adoption opens the lineage tip (resolved_id) while pinning the durable id,
same split as the preferred_session path.
Symptom: switching between bots forked a brand-new "Bot Chat" for the
returned-to bot on EVERY switch, burying the user's real forever-chat
(one report: a 930-message chat displaced by 7 forks in one morning).
Root cause is a self-perpetuating loop between three parties:
- state.db enforces UNIQUE(title): the first fork permanently squats
the "Bot Chat" title; every later mint's title request is silently
dropped by set_session_title (returns 0, no error to the caller).
- The post-turn LLM auto-titler then names the untitled fork from its
kickoff content ("Assistant introduction request #2", ...).
- openBotCanonicalChat's identity check is title-string matching, so
the renamed fork reads as "not plumbing" -> corrupted metadata ->
clear pin -> mint again. Grandfathered pre-convention chats (real
history, derived titles) hit the same branch and are forked away
from immediately.
Fix, two invariants:
1. Adopt-before-mint: createCanonicalChat first scans the profile via
session.list include_hidden:true for an existing "Bot Chat" row and
re-pins it instead of creating. The UNIQUE index makes this an exact
registry lookup (at most one match), not a heuristic. Older gateways
without include_hidden find nothing and fall through to mint.
2. A pin that resolves to a NON-plumbing session carrying real history
is the user's conversation - keep it and open it (title drift is
metadata damage, not ownership loss). Only a pin resolving to an
EMPTY stray draft is treated as corrupted and replaced (which now
goes through adoption first).
Also removes the right-click -> Sessions per-bot stored-session browser
(ProfileSessionsWorkspace and its atoms/query/rows). Bot Mode's product
contract is ONE forever-chat per bot; a browser listing every hidden
plumbing session contradicts that and confused users into opening dead
forks. The Sessions workspace test goes with it; the include_hidden
source-shape test now pins the adoption scan instead, and a new suite
(canonical-chat-adopt-before-mint.test.mjs) covers both invariants plus
the older-gateway fallback.
An unset checkbox's `.value` is "on" per the HTML spec, and the inventory read
the value before the state — so a ticked box and an empty one both came back as
`value: "on"`, and the agent had no way to tell them apart. Anything built on
reading a form back ("is the newsletter box already checked?") was answering
from a coin flip.
The state check now runs first, gated on the input's type rather than on
`checked` being defined — it is defined, as false, on every input including
text fields, which is what made the original ordering look reasonable. ARIA
checkboxes and switches built out of divs read `aria-checked`, which the old
branch would have missed anyway since a div has no `.checked`.
Found by putting the extracted helper under a direct test. It was unreachable
before: the only way to observe it was to run a whole action against a whole
document.
act-in-page.ts had grown to 1,054 lines: one function holding twenty-eight
closures, of which roughly four hundred lines were pure string and geometry
work that never touched the holder, the action, or any page state. None of it
could be tested. `slug`, `affinity`, `coin` and the rest were reachable only by
running a whole action against a whole document and inferring what they must
have done.
Four modules now, at the seams the dependency graph actually has:
types.ts the six shared interfaces, re-exported from act-in-page.ts so
no import site anywhere changes
naming.ts what an element is called — labels, slugs, stems, anchors
visibility.ts whether it is really there and whether it is on screen
identity.ts the re-bind ladder and handle minting
Each is a factory rather than a bag of exports, and that shape is load-bearing
rather than taste. These sources are stringified into the guest page, where
module scope does not exist; separate exports would be separate names for the
bundler to mangle independently of the call sites inside the stringified core.
A factory that stringifies whole has no cross-module reference to break. The
core takes the kits as a parameter for the same reason — it names nothing it
did not receive.
That failure mode deserves spelling out, because it is why this is not a plain
import. The renderer minifies. An imported binding referenced inside the core
would be renamed to something the injected bundle never declares, and it would
break in packaged builds ONLY — green in dev, green under vitest, broken for
users. `actEngineSource()` is now the single supported way to obtain the
source, so a partial injection is not something a caller can express.
The self-containment test moves with it, and gets stronger: it evaluates the
assembled bundle rather than one function, which is what the guest actually
receives. It caught the contract change on the first run.
Bodies moved unchanged; the only edits are closure captures becoming
parameters, and `coin` taking the counter it used to read off the holder. The
engine is 634 lines, all of it orchestration. The 53 existing engine tests pass
untouched, which is the evidence the move preserved behaviour, and 27 new tests
exercise the extracted helpers directly for the first time.
Every drive_preview action answered with the entire inventory — around 120
elements of ref, role, label, and an up-to-eight-rung `:nth-child` selector
chain. On a real app shell that was ~24.5k characters, re-sent after every
click, so a ten-step task paid for ten copies of a page that had barely moved.
Handles are now durable and legible. An element is named after what it is and
what it says — `btn-sign-in`, `inp-email`, `srch-search-projects` — minted once
per page and never reused, with duplicates disambiguated as `btn-edit`,
`btn-edit-1`. Each one remembers a stable attribute, its role, its accessible
name, and the nearest landmark it sits in, so when a framework destroys the
node and builds a new one the handle moves across and the agent is told
`rebound` rather than being handed a removal it has to react to and an addition
it has to re-read. The re-bind ladder is anchortree's (Apache-2.0), minus its
geometry rung, which can never clear the threshold on its own.
Because the handles hold, the first look at a page returns the inventory and
every look after it returns only what moved. `changed` carries the ref and
whichever of label/value/disabled actually shifted — role and selector are
absent by construction, since a change in either would mean the re-bind ladder
was looking at a different element. A delta gives way to a full re-read when
half the page is new, where there is nothing left to reuse.
The selector column is gone with it. It was 74% of the inventory on an
85-element page, nothing downstream ever read it, and a positional chain is
wrong the moment a sibling appears. An `#id` or `[data-testid]` survives when
the page offers one; everything else is addressed by handle.
Legibility is what makes the delta work rather than a nicety. `+ btn-sign-in`
on turn nine reads on its own, where `+ @e42` sends the model back to an
inventory twenty thousand tokens ago.
Measured on an 85-element app shell: 18,693 -> 4,930 characters for a baseline,
and a steady turn that moved two things costs ~200.
Driving someone's browser invisibly is unnerving, and this browser is the one
they are signed into. The pane now draws the field the agent can reach, a box
round what it is touching, a cursor that goes there, and a wipe over text it
just read — one cursor primitive and one mark primitive, in a closed shadow
root so the agent's own inventory cannot see them. Marks carry the same handle
the agent addresses them by, so the word on screen and the word in the
transcript are the same string.
The point is supervision rather than decoration: a person glancing at the pane
can tell what is about to happen to their live session, and stop it.
It also has to cover the waiting. The agent flashes through a click in under a
second and then sits idle for the twenty to a hundred seconds the model spends
deciding what to do next, which is most of the wall clock of any task — so the
surface used to look broken during the part where it was working hardest. A
think stage runs off the $busy edge, sparsely flashing elements from the field
the last action left behind, and rest stops it. It guards itself: started
before there is an overlay or a field, it idles until there is one, so it can
be raised on the turn boundary without knowing whether anything has been
inventoried yet.
read_preview had the same hole from the other side. Reading is the cheapest
thing the agent does — hundredths of a second between two model round trips —
so paging through a document left the pane dark for twenty seconds immediately
after the one moment that showed anything. It draws a top-to-bottom wipe over
the text it took, and that is the one stage allowed to be a wipe: reading is
the only thing the agent does to a page in an order a person could follow.
Both go through preview-nudge, which says a single stage to an overlay the page
already has rather than re-shipping the engine to narrate. On a page the agent
never acted on it is a no-op, which is the honest answer — chrome there would
be a lie about what it did.
Everything respects prefers-reduced-motion.
A dispatched MouseEvent is untrusted, so hover menus never opened and any
control that gates on isTrusted ignored it. The pane now sends input through
the webview itself: the pointer travels to its target and the page cannot tell
it from a hand.
The in-app browser was a one-way mirror. open_preview put a page in the pane
and read_preview read its text back, but nothing could touch it. A click meant
falling back to the browser_* tools, which drive a separate Chromium the user
cannot see — so "log into this and pull my invoices" happened in a different
browser from the one on screen, with none of the sessions the user is already
signed into.
Four pieces, and they only make sense together:
· an in-page engine that inventories what is interactable and performs the
verb, injected as source because it has to run inside the guest page;
· the preview.act.request bridge from the gateway into the pane;
· drive_preview, for acting: elements, click, type, scroll, press, and the
pane's own back/forward/reload;
· annotate_preview, for marking without acting.
Those last two started as one tool doing two unrelated jobs. Leaving a mark is
not an action — it outlives the turn that drew it — so it gets its own verb,
and the interaction verb gets a name that says what it does.
Gating is the existing surface rule: desktop_ui folds in on session
source: 'desktop', and the bridge refuses to act for a background session, so a
turn running behind the user's back cannot reach into the page they are working
in.
Two details worth a reviewer's attention. Typing assigns through the
prototype's value setter, because React shadows value with its own accessor and
ignores an input event whose value it believes it already wrote — a plain
el.value = … types into a field that snaps back on the next render. And
clicking replays the pointer/mouse pair before activation, because frameworks
bind to mousedown as often as to click.
The #89788 gate read main-tab ownership from a plain module Map — invisible
to React — and openGroupChat set the selection atom before recording the
tab. Every open therefore rendered BotsPane in a selected-but-unowned
window, painting the in-pane room beside the main tab, and the duplicate
stuck because the later Map write repaints nothing.
- $groupMainTabsRev atom shadows tab-map membership; all mutations go
through recordGroupMainTab/dropGroupMainTab; BotsPane subscribes, so the
in-pane gate re-evaluates on tab open/close.
- openGroupChat records the tab BEFORE setting the selection atom; older
desktops without the main-window door (and a throwing door) still get
the in-pane fallback.
- Regression tests: gate is false at the instant the selection atom flips
(fails on the old ordering — sabotage-verified), and rev bumps on tab
open/close.
Three fixes from one remote-gateway (VPS) debug bundle, all live-reproduced
and re-verified on a headed Electron seat via CDP:
- Bots roster no longer shrinks during a gateway outage: source enumeration
is bounded (10s/source instead of wedging the roster IPC >30s behind a
dead dial) and a bounced remote source keeps painting its last-known
profile list (was SSH-only), so 4 bots never show as 2 mid-outage.
- Pool backend spawns that die before the child exists (forced-local spawn
of a profile that only exists on the remote) now log the failure to
desktop.log, and the profile-exists guard runs BEFORE the Starting line —
no more orphaned no-READY/no-exit spawn bursts in bundles.
- An SSH host-key change (VPS reinstall) is classified terminal like a
reauth rejection: it latches, the boot-failure overlay shows the
ssh-keygen -R guidance, and the renderer stops the infinite boot-retry
loop (one bundle had 157 consecutive failures over 2.5h). Reset/repair/
apply-config clear the latch; live-verified Retry-after-fix boots clean.
Remote-mode installs had every update affordance (About panel Update now,
⌘K Update Hermes, the update-ready toast) pointed at the BACKEND only, so
users updated their VPS forever while the desktop app itself sat weeks
stale — with no signal it was behind (the skew warning only fired the
other way). Reported by Santiago Sarceda: mac app on v0.20.0 kept
repro'ing UI bugs fixed on main because 'update' never touched the app.
- store/updates.ts: applyEverythingUpdate() orchestrates all targets —
active backend first (detailed progress), every other eligible
registered gateway via the existing Electron fan-out (cloud rows skip),
the client LAST (its apply relaunches the app). startActiveUpdate/
requestActiveUpdate route through it whenever more than one update
target exists; single-machine installs keep the one-button flow.
- After ANY successful backend update, the client version is re-checked
and a one-click 'Update desktop app' warning fires if the GUI is still
behind — the reverse-skew signal that didn't exist.
- electron: hermes:connections:update-all accepts optional excludeIds so
the flow doesn't double-dispatch the active backend / local runtime.
- i18n: 7 new updates.* keys across en/zh/zh-hant/ja/ar.
- docs: desktop.md Updating section + multi-connection guide.
- tests: 10 new cases (gating, ordering, exclusions, failure isolation,
memoization, nudge on/off).
Two things a genuinely fresh instance surfaced that no existing profile could.
The renderer's mode fell back to `light` when nothing was stored, so a
dark-mode desktop opened a white window on first launch. Main already
defaulted its own themeSource to `system`, so the two disagreed at boot — and
once translucency became per-appearance it also handed those users light's
much heavier tint, tuned for a bright desktop they don't have. Both the
normalizer and the SSR fallback now say `system`; an explicit choice still
wins.
The accent plugin reached straight into `@/components` and `@/themes`, which
the plugin lint rule exists to prevent: plugins import `@hermes/plugin-sdk`
and nothing else, so the app can move its internals without breaking them.
The fix is to widen the SDK rather than exempt the plugin — it now exports the
OKLCH colour maths, `useTheme`, `retintTheme`, and the accent override, so any
plugin can derive a palette instead of hardcoding one.
Midnight is monotone in a way none of the other skins are, and it turns out
to be a good test of the retint: its ring is `#8b80e8` under a `#ddd6ff`
primary — the same violet at a different lightness, not a repeat of one hex.
Matching accent slots by exact equality with the primary left that ring
behind, so re-seeding produced a half-retinted theme with a purple ring under
a teal accent. Slots now join the family by HUE, within a tolerance, and each
keeps its own lightness and chroma when it moves. A theme that deliberately
runs a deeper ring keeps that relationship instead of being flattened onto
one colour.
Near-greys are excluded by chroma rather than hue, so mono's neutral ring
still stays exactly where its author put it.
The light default carries a single point of fade so the window edge reads as
glass rather than as paint. That point followed anyone who dragged the tint
to zero, leaving a window that asked to be opaque sitting at 0.9999.
Fade now applies only while glass is actually active, not merely selected.
Finding a colour by hex is guesswork; finding one by eye needs a picker that
does not lie about where you will land. HSV crushes the whole blue family
into a narrow band of its hue rail, so dragging "to blue" puts you on pure
sRGB blue, which reads violet — every blue that actually looks blue lives in
a few degrees you cannot reliably hit there.
This one is OKLCH. The hue rail is perceptually even and previews the
current colour at every hue rather than showing a generic rainbow, and the
field is a canvas drawn per-pixel through the real conversion, so its curved
edge is the true sRGB gamut boundary — every pixel is a colour the display
can show. Dragging repaints the whole app against the real derivation.
It ships off (`defaultEnabled: false`) and holds no persisted state: the
override clears on dispose, so turning the plugin off returns every surface
to the authored theme rather than stranding a colour with no control to
clear it. The retint itself stays in core, where Appearance settings and the
command palette can reach it.
Translucency was one number serving both appearances and both platforms,
resting at zero. A lever that starts at zero is a feature nobody finds, and
one number cannot serve four situations: a tint that reads as a whisper over
a dark palette is a milky sheet over a light one, and the same numbers that
read as frost on macOS vibrancy read as a washed sheet over Windows acrylic,
which composites its own tint in DWM before the page is drawn.
So the state splits. `mode` stays global — clear versus glass is a choice
about the window, not the palette — while the values resolve through a
ladder, per key: the appearance you are looking at, then a shared base, then
the platform default. Tuning light mode stays in light mode; an untouched
dark keeps inheriting. A v1 state lands in base, so a window someone already
tuned crosses the upgrade with exactly what was on screen.
Main reads the same defaults at window creation, because a window born
opaque cannot reliably be swapped to glass afterwards.
The chat backdrop goes off by default in the same pass: it was competing
with the glass field for the same surface.
Every other dot in the set reads a token; unread was a hardcoded
`emerald-500`. On a blue theme that left eight green marks down the sidebar
fighting the palette around them.
It now paints `--ui-success`, a success green rotated part of the way toward
the accent along the shortest hue arc. Partway rather than all the way,
because landing on the accent would make "finished" and "running" the same
colour. The default costs nothing by construction: emerald sits at 162
degrees and GitHub green at 148, so a quarter rotation moves the dot about
three degrees. The work only happens when the accent is genuinely far away,
which is the case that was clashing.
A palette's accent is not one value, it is a family: the seed plus the soft
surfaces mixed from it — seven slots per appearance in nous, all derived
from one colour. `retintTheme` moves the whole family at once, reusing the
converter's own mix ratios so re-seeding a theme with its existing accent
returns the identical object.
The colour work this needed is the interesting half. Mixing toward white in
gamma-encoded sRGB bends hue: a saturated blue lands 7.6 degrees violet of
where it started, which is how a clean blue accent produced a lavender
selection row. `mixOklab` holds the hue and moves only chroma and lightness.
`ensureContrastOklch` adapts a seed for an appearance that cannot carry it
by walking lightness rather than blending toward white, which would gut the
chroma and wash the brand colour out.
`readableOn` picked text colour from a luminance threshold, and got five
shipped accents wrong in the direction that matters — white on GitHub's own
dark green measured 3.29:1, below AA, where near-black measures 5.50:1. It
now measures both candidates and takes the better one.
The bundled skins were an ad-hoc set that had drifted from anything
recognisable. They are now forks of the VS Code themes people already know,
produced by the repo's own marketplace converter rather than transcribed by
hand, so each palette is byte-identical to what installing the extension
would give you.
`nous` keeps GitHub's chrome and carries the brand blue as its accent. Two
seeds, one colour: `#0053fd` reads at 5.4:1 on the light sidebar but only
3.6:1 on the near-black dark one, so dark carries `#4a84fe` — the same hue
at 263°, lifted to clear AA at 5.9:1. Everything else in both palettes is
upstream's, and a test holds that line.
`github` ships alongside it, unmodified, so the original stays available on
its own terms instead of only existing as the thing nous diverged from.
Catppuccin, Everforest and Solarized join them; the skins nobody could name
are retired, with `midnight` folded into the retired list so anyone sitting
on it lands on nous rather than a dead name.
Renaming a bot (Bot Mode title or 'hermes profile rename' display_name)
changed the roster row but not what the user could @-tag it with — mentions
still only resolved the original profile handle, and the composer
autocomplete never offered the new name.
- mentionNameForms()/botFriendlyNames()/botMentionTag(): one resolver for
the taggable forms a friendly name yields (slugged + collapsed), with
reserved tokens (hermes/default/everyone/all/user) excluded so a rename
can never hijack them.
- resolveRosterMentions() and parseGroupChatMentions() accept the friendly
forms alongside the profile name/handle (both keep working).
- Composer @ autocomplete (global provider + group-room popover) inserts
the renamed tag and prefix-matches on tag, handle, and display name.
- Mention middleware's cold-cache fallback now runs the same resolver
instead of a bare-names-only parse, so renamed tags resolve there too.
- durableGroupChatMembers persists title/display_name so renamed-tag
mentions survive connection switches in cross-machine rooms.
- Docs: bot-mode.md documents renamed tags.
Stacking was the ladder's last rung, but a tile can be dragged far narrower
than the stacked controls row costs. Two more width stages, from the same
measured-width engine: under 260 the three voice toggles fold into the one
menu HUD mode already uses, and under 180 the model pill and the menu drop
too — input and Send, nothing else, down to the 80px pane floor. The model
pill also shrinks and truncates between stages instead of holding its width,
and the metrics hook returns one ComposerFit so a resize re-renders only when
a stage actually flips.
The surface is a grid that never declared a column, and an implicit auto
column sizes to its items' min-content. The coding status row (branch, PR
chip, worktree path, counts — none of it wrapping) out-measured narrow panes
and silently set the track wider than the surface; every w-full child laid
out against that phantom width and overflow-hidden clipped the right edge,
send button first. grid-cols-[minmax(0,1fr)] pins the track to the surface.
Remote mode used to return before asking the host for repos, and never sent
profile, so the sidebar stayed on the launch list. Ask discover_repos to scan,
forward the focused profile on every projects call, and drop late responses
from a profile the user already left.
Co-authored-by: Chen Jin <Enough1122@users.noreply.github.com>
Co-authored-by: Ryan Weddle <weddle@gmail.com>
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Co-authored-by: webtoolbox <1911826+webtoolbox@users.noreply.github.com>